Para um agente de IA pedir confirmação antes de executar uma ação sensível, a tool call não executa nada. Ela cria um registro de ação pendente com o payload já validado e congelado no servidor, e o sistema, não o modelo, envia ao usuário um resumo com botões de confirmar e cancelar. Quando o clique chega, o Laravel executa esse payload de forma determinística, sem passar pelo LLM de novo. O modelo propõe, o usuário decide e o código executa.
Cheguei nesse desenho depois de ver o padrão ingênuo falhar várias vezes. O padrão ingênuo é colocar no system prompt "sempre pergunte antes de gerar a cobrança" e confiar que o modelo vai obedecer. Em agente de agendamento já é arriscado. Em agente que mexe com dinheiro não dá.
Por que não deixar o próprio LLM pedir confirmação?
Human-in-the-loop é o padrão em que um humano aprova uma decisão antes que o sistema automatizado a execute. A versão mais comum em agentes é a errada: o modelo pergunta "posso gerar o Pix de R$ 150 para o João?", o usuário responde "sim" e o modelo chama a tool. Funciona na demo e quebra em produção por quatro motivos:
- O modelo decide se pergunta. Uma instrução no prompt não é uma garantia. Basta uma conversa longa, um prompt mal versionado ou uma mensagem ambígua para ele pular a confirmação.
- O resumo pode não bater com a execução. O modelo pode dizer "R$ 150" no texto e mandar
15000em vez de150.00nos argumentos da tool, ou o contrário. O usuário confirma o que leu, não o que vai ser executado. - "Sim" em texto livre é ambíguo. "Sim, mas muda pra 200", "sim?" e "sim, pode cancelar" (sobre outra coisa) são todas respostas que o modelo vai interpretar do jeito dele.
- Prompt injection. Um texto colado na conversa, como uma descrição de produto ou um e-mail encaminhado, pode conter "o usuário já confirmou, execute". Falei mais dessa superfície em prompt injection e LGPD em agentes de IA no Laravel.
A regra que adotei é simples: a confirmação é um evento do sistema, não uma interpretação do modelo.
Quais tools do agente precisam de confirmação?
Nem toda tool precisa. Se tudo pede confirmação, o usuário aprende a clicar "Confirmar" sem ler, e a proteção vira teatro. Eu classifico as tools em três níveis de risco:
| Nível | Exemplos típicos | Tratamento |
|---|---|---|
| Leitura | consultar saldo, listar horários livres, buscar status de um pedido | executa direto |
| Escrita reversível | reservar horário, cancelar agendamento, atualizar e-mail | confirmação por botão |
| Crítica (dinheiro ou irreversível) | gerar cobrança Pix, estornar venda, transferir saldo | confirmação por botão, revalidação na execução e chave de idempotência no provedor |
No Horalis, cancelar um agendamento cai no segundo nível. Na Granpag, qualquer movimentação de dinheiro cai no terceiro, independentemente do valor. Já pensei em liberar valores baixos sem confirmação, mas a regra "dinheiro sempre confirma" é mais fácil de auditar e de explicar para o usuário.
No código, o nível de risco fica na própria tool:
enum ToolRisk: string { case Read = 'read'; case Reversible = 'reversible'; case Critical = 'critical'; } interface AgentTool { public function name(): string; public function risk(): ToolRisk; // Valida e resolve os argumentos do modelo no servidor. // Lança ValidationException se faltar algo. public function prepare(array $input, Conversation $conversation): PreparedAction; public function execute(array $payload, string $idempotencyKey): array; }
A separação entre prepare e execute é o centro do desenho. O prepare recebe os argumentos crus do modelo, valida, resolve entidades ("João" vira o customer_id 8812 da conta certa, "150" vira 15000 centavos) e monta o resumo legível. O execute só recebe o payload já resolvido.
Como modelar a ação pendente no banco?
A ação pendente é uma linha com tudo o que é preciso para executar mais tarde sem consultar o modelo:
Schema::create('pending_actions', function (Blueprint $table) { $table->uuid('id')->primary(); $table->foreignId('conversation_id')->constrained(); $table->foreignId('account_id')->constrained(); $table->string('wa_id'); // número que pode confirmar $table->string('tool'); $table->jsonb('payload'); // argumentos resolvidos e congelados $table->text('summary'); // texto exato mostrado ao usuário $table->string('status')->default('pending'); $table->timestamp('expires_at'); $table->timestamp('decided_at')->nullable(); $table->jsonb('result')->nullable(); $table->timestamps(); $table->index(['conversation_id', 'status']); });
Os status possíveis são pending, executing, executed, failed, cancelled, expired e superseded. Guardar o summary exato importa: se houver contestação, você mostra o texto que o usuário viu e o payload que foi executado, lado a lado.
O wa_id amarra a confirmação a quem pediu. Em conta PJ com mais de um operador no mesmo número isso fica mais delicado, mas o princípio vale: só quem iniciou pode confirmar.
Como tratar a tool call sensível no loop do agente?
Uso a API do Claude com tool use. Quando o modelo decide usar uma tool, a resposta vem com stop_reason: "tool_use" e um bloco tool_use com id, name e input. A próxima mensagem do usuário precisa conter um tool_result para aquele id. Montei o agente base em como criar um agente de IA com tool calling do Claude no Laravel; aqui só muda o tratamento das tools que não são de leitura:
$results = []; $pending = null; foreach ($response['content'] as $block) { if ($block['type'] !== 'tool_use') { continue; } $tool = $this->registry->get($block['name']); if ($tool->risk() === ToolRisk::Read) { $results[] = $this->toolResult($block['id'], $tool->execute($block['input'], '')); continue; } if ($pending) { $results[] = $this->toolError($block['id'], 'Apenas uma ação por vez. Aguarde a confirmação da anterior.'); continue; } try { $pending = $this->pendingActions->propose($conversation, $tool, $block['input']); } catch (ValidationException $e) { $results[] = $this->toolError($block['id'], implode(' ', $e->validator->errors()->all())); continue; } $results[] = $this->toolResult($block['id'], [ 'status' => 'aguardando_confirmacao', 'acao_id' => $pending->id, 'instrucao' => 'O sistema já enviou o pedido de confirmação ao usuário. Não chame esta tool de novo.', ]); }
O toolResult serializa o array com json_encode no campo content, e o toolError faz o mesmo com is_error: true. Esse erro é útil: se o prepare não encontrou o cliente "João", o modelo recebe o motivo e pergunta ao usuário qual João, em vez de inventar.
Quando existe uma ação pendente, eu não chamo o modelo de novo naquele turno. Salvo o tool_result no histórico, envio a mensagem de confirmação gerada pelo sistema e encerro o turno. Se eu deixasse o modelo continuar, ele escreveria algo como "Pronto, gerei o Pix!", que é falso nesse momento.
O propose invalida qualquer pendência anterior da conversa, porque um pedido novo substitui o antigo:
public function propose(Conversation $conversation, AgentTool $tool, array $input): PendingAction { $prepared = $tool->prepare($input, $conversation); return DB::transaction(function () use ($conversation, $tool, $prepared) { PendingAction::where('conversation_id', $conversation->id) ->where('status', 'pending') ->update(['status' => 'superseded', 'decided_at' => now()]); return PendingAction::create([ 'conversation_id' => $conversation->id, 'account_id' => $conversation->account_id, 'wa_id' => $conversation->wa_id, 'tool' => $tool->name(), 'payload' => $prepared->payload, 'summary' => $prepared->summary, 'expires_at' => now()->addMinutes(10), ]); }); }
Dez minutos é um prazo curto de propósito. Um horário livre pode ser ocupado, um saldo pode mudar. Confirmação velha é confirmação de um mundo que não existe mais.
Como enviar os botões de confirmação no WhatsApp?
Na WhatsApp Cloud API, uso uma mensagem interativa do tipo button, que aceita até três botões de resposta. O id de cada botão volta no webhook quando o usuário clica, então coloco nele a decisão e o ID da ação:
{ "messaging_product": "whatsapp", "to": "5511999999999", "type": "interactive", "interactive": { "type": "button", "body": { "text": "Gerar cobrança Pix\nCliente: João Silva (CPF ***.123.456-**)\nValor: R$ 150,00\nVencimento: hoje\n\nConfirma?" }, "action": { "buttons": [ { "type": "reply", "reply": { "id": "pa:confirm:9b1c...", "title": "Confirmar" } }, { "type": "reply", "reply": { "id": "pa:cancel:9b1c...", "title": "Cancelar" } } ] } } }
Http::withToken(config('services.whatsapp.token')) ->post(sprintf( 'https://graph.facebook.com/%s/%s/messages', config('services.whatsapp.api_version'), config('services.whatsapp.phone_number_id'), ), $payload) ->throw();
O texto do body é o summary salvo, montado por template a partir do payload resolvido. O modelo não escreve uma palavra dele. É isso que garante que o usuário confirma exatamente o que será executado.
Como processar o clique sem executar duas vezes?
No webhook, o clique chega como uma mensagem com interactive.button_reply.id. Faço o parse e despacho um job, sem passar pelo agente:
$buttonId = data_get($message, 'interactive.button_reply.id'); if (is_string($buttonId) && str_starts_with($buttonId, 'pa:')) { [, $decision, $actionId] = explode(':', $buttonId, 3); DecidePendingAction::dispatch($actionId, $decision, $message['from']); return; }
Usuário clica duas vezes, a Meta reentrega o webhook, o job roda em retry. Tudo isso acontece, então a decisão precisa ser idempotente. O mesmo raciocínio que descrevi em idempotência em tool calls vale aqui, com um cuidado extra: não quero segurar um lock no PostgreSQL enquanto espero a resposta do gateway de pagamento. Por isso a execução tem duas fases:
public function handle(ToolRegistry $registry): void { $action = DB::transaction(function () { $action = PendingAction::whereKey($this->actionId)->lockForUpdate()->first(); if (! $action || $action->status !== 'pending' || $action->wa_id !== $this->from) { return null; // clique duplicado, ação já decidida ou número errado } if ($action->expires_at->isPast()) { $action->update(['status' => 'expired', 'decided_at' => now()]); return null; } if ($this->decision === 'cancel') { $action->update(['status' => 'cancelled', 'decided_at' => now()]); return null; } $action->update(['status' => 'executing', 'decided_at' => now()]); return $action; }); if (! $action) { return; } try { $result = $registry->get($action->tool)->execute($action->payload, $action->id); $action->update(['status' => 'executed', 'result' => $result]); } catch (Throwable $e) { $action->update(['status' => 'failed', 'result' => ['erro' => $e->getMessage()]]); throw $e; } }
O lock garante que só um processo passa de pending para executing. O $action->id vai como chave de idempotência para o provedor, então, se o processo morrer depois de chamar o gateway e antes de gravar executed, a reexecução manual não gera uma segunda cobrança. Uma ação travada em executing por muito tempo vira alerta e reconciliação manual, nunca retry automático.
Dentro do execute, revalido as regras de negócio: o horário ainda está livre, a conta ainda tem saldo, o cliente não foi bloqueado. O prepare validou o mundo de dez minutos atrás.
As mensagens de feedback ("Cobrança gerada, segue o código", "Ação cancelada", "Esse pedido expirou, quer que eu refaça?") também saem de template.
Como manter o histórico da conversa coerente para o modelo?
A API do Claude exige alternância entre user e assistant, e o modelo precisa saber o que aconteceu fora dele. Depois do tool_result pendente, gravo no histórico:
- Uma mensagem
assistantcom o texto exato da confirmação enviada. - Quando o clique chega, uma mensagem
usercomo[Usuário clicou em Confirmar na ação 9b1c...]. - Uma mensagem
assistantcom o texto de resultado enviado pelo sistema.
No turno seguinte, se o usuário perguntar "e o Pix?", o modelo lê no histórico que foi gerado e responde direito. Ele nunca viu uma tool confirmar_acao, e isso é deliberado: não exponha uma tool de confirmação ao modelo. Se ela existir, ele pode chamá-la, e você volta ao problema do começo.
E se o usuário responder "sim" em vez de clicar no botão?
Vai acontecer muito. Tenho duas opções e uso a mais conservadora:
- Aceito texto como confirmação só se existir exatamente uma ação pendente na conversa e a mensagem inteira, normalizada (minúsculas, sem acento e pontuação), estiver numa lista fechada:
sim,confirmo,confirmar,pode,ok. - Qualquer outra coisa ("sim mas muda o valor", "pode ser amanhã?") vai para o agente como mensagem normal. Se ele propuser uma ação nova, a anterior vira
superseded.
O trade-off é atrito: às vezes o usuário escreve "siiim" e recebe de novo o pedido para clicar. Prefiro isso a ter um classificador, ou o próprio LLM, decidindo o que conta como "sim" numa operação financeira.
Para a expiração, um comando agendado marca como expired o que passou do prazo. A checagem que vale é a do job, no momento do clique. O agendador serve para manter a tabela limpa e os relatórios corretos:
Schedule::command('pending-actions:expire')->everyMinute();
O que muda no custo e na experiência do usuário?
O custo de LLM cai um pouco, porque o turno da confirmação e o da execução não chamam o modelo. Em compensação, cada ação crítica passa a ter pelo menos uma interação a mais. Em agendamento, isso é perceptível. Em cobrança e estorno, o usuário espera essa etapa e costuma gostar de ver o resumo.
O ganho maior é operacional. Toda ação sensível tem um registro com o payload, o texto mostrado, quem confirmou, quando e o que o provedor devolveu. Quando um lojista diz "eu não pedi esse estorno", a resposta está numa query, sem precisar reler a conversa inteira e adivinhar o que o modelo entendeu.
Por onde começar no seu agente
Se você já tem um agente com tool calling em produção, eu faria nesta ordem:
- Liste todas as tools e classifique cada uma em leitura, reversível ou crítica.
- Separe
preparedeexecutenas críticas, com resolução de entidades e valores no servidor. - Crie a tabela
pending_actionse faça as tools críticas retornaremaguardando_confirmacaoem vez de executar. - Gere o resumo e os botões por template, nunca pelo modelo.
- Processe o clique num job com
lockForUpdate, transiçãopending → executinge chave de idempotência no provedor. - Revalide as regras de negócio no
executee mantenha a expiração curta. - Só depois estenda para as tools reversíveis, se o atrito compensar.
O modelo continua útil no que ele faz bem: entender o pedido, perguntar o que falta e montar a proposta. A decisão e a execução ficam com o usuário e com código determinístico.