# Como exigir confirmação humana antes do agente de IA executar ações no Laravel

> Tutorial para o agente de IA pedir confirmação com código no WhatsApp antes de estornar, cobrar ou cancelar, com pendência no PostgreSQL, expiração e execução idempotente.

Por Lucas Roberto · IA · publicado em 2026-10-24 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/como-exigir-confirmacao-humana-antes-do-agente-de-ia-executar-acoes-no-laravel

Para impedir que um agente de IA execute sozinho uma ação irreversível, a tool não deve executar nada. Ela grava uma **ação pendente** no banco. O sistema, e não o modelo, envia ao usuário um resumo com um código, e só uma resposta validada por código determinístico libera a execução num job idempotente. O LLM sugere a ação, o humano decide, e o Laravel garante que a decisão foi do humano.

Esse padrão se chama *human-in-the-loop*: um ponto do fluxo automatizado em que uma pessoa precisa aprovar antes de seguir. Em atendimento de fintech isso não é opcional. Estorno, link de cobrança, cancelamento de agendamento e alteração de dados bancários são ações em que um erro do modelo, ou uma [prompt injection que manipula o agente](/blog/prompt-injection-e-lgpd-em-agentes-de-ia-no-laravel-como-proteger-dados-e-acoes), custa dinheiro real.

## O que você vai ter funcionando no final

- Tools do agente classificadas por risco: as de leitura executam na hora, as de escrita viram pendência.
- Uma tabela `pending_actions` no PostgreSQL com código, expiração, limite de tentativas e no máximo uma pendência aberta por conversa.
- Mensagem de confirmação montada pelo código a partir do banco, sem texto gerado pelo modelo.
- Interceptação da resposta "SIM 1234" antes de a mensagem chegar no LLM.
- Job que executa a ação uma única vez, revalida o estado e repassa uma chave de idempotência ao provedor.
- Testes com `Http::fake` provando que nada é executado sem confirmação.

**Pré-requisitos:**

1. Laravel 11 ou 12, PHP 8.2+.
2. PostgreSQL (uso índice único parcial, que é recurso do PostgreSQL).
3. Fila configurada (Redis com Horizon, ou o driver `database`).
4. Chave da API da Anthropic. Os exemplos chamam a Messages API direto com o `Http::` do Laravel, sem SDK.
5. Um agente com tool calling já funcionando. Se ainda não tem, comece pelo [tutorial de agente com tool calling do Claude para agendamentos no WhatsApp](/blog/como-criar-um-agente-de-ia-com-tool-calling-do-claude-no-laravel-para-agendamentos-no-whatsapp).

*Tool calling* é o mecanismo em que o modelo, em vez de responder texto, devolve um bloco pedindo para o seu código executar uma função com determinados argumentos.

## 1. Quais ações do agente de IA precisam de confirmação?

Antes do código vem a classificação. Uso um critério simples: se a ação mexe em dinheiro, é difícil de desfazer ou afeta terceiros, ela pede confirmação.

| Tool | Efeito | Reversível? | Confirmação |
|---|---|---|---|
| `consultar_vendas` | Leitura | Não se aplica | Não |
| `consultar_saldo` | Leitura | Não se aplica | Não |
| `gerar_link_pagamento` | Cria cobrança para terceiro | Parcialmente | Sim |
| `estornar_venda` | Devolve dinheiro | Não | Sim |
| `cancelar_agendamento` | Libera horário, avisa profissional | Difícil | Sim |

A regra que sustenta o resto do tutorial é esta: **o modelo nunca tem uma tool de "confirmar"**. Se existir uma tool `confirmar_acao`, um texto malicioso dentro de um PDF ou de uma mensagem encaminhada pode convencer o modelo a chamá-la. A confirmação acontece fora do LLM.

## 2. Criar a tabela de ações pendentes no PostgreSQL

```bash
php artisan make:migration create_pending_actions_table
php artisan make:model PendingAction
```

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('pending_actions', function (Blueprint $table) {
            $table->id();
            $table->foreignId('conversation_id')->constrained();
            $table->foreignId('customer_id')->constrained();
            $table->string('tool');
            $table->jsonb('arguments');
            $table->text('summary');
            $table->string('code', 4);
            $table->unsignedSmallInteger('attempts')->default(0);
            $table->string('status')->default('pending');
            $table->jsonb('result')->nullable();
            $table->timestamp('expires_at');
            $table->timestamp('confirmed_at')->nullable();
            $table->timestamp('executed_at')->nullable();
            $table->timestamps();
        });

        DB::statement("CREATE UNIQUE INDEX pending_actions_one_open_per_conversation ON pending_actions (conversation_id) WHERE status = 'pending'");
    }

    public function down(): void
    {
        Schema::dropIfExists('pending_actions');
    }
};
```

O índice único parcial garante no banco que cada conversa tem no máximo uma pendência aberta. Isso elimina a ambiguidade de "SIM" se referir a qual ação, e não depende de o código da aplicação estar certo.

O enum de status:

```php
<?php

namespace App\Enums;

enum PendingActionStatus: string
{
    case Pending = 'pending';
    case Confirmed = 'confirmed';
    case Executing = 'executing';
    case Executed = 'executed';
    case Failed = 'failed';
    case Expired = 'expired';
    case Cancelled = 'cancelled';
}
```

O model:

```php
<?php

namespace App\Models;

use App\Enums\PendingActionStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class PendingAction extends Model
{
    protected $guarded = [];

    protected function casts(): array
    {
        return [
            'arguments' => 'array',
            'result' => 'array',
            'status' => PendingActionStatus::class,
            'expires_at' => 'datetime',
            'confirmed_at' => 'datetime',
            'executed_at' => 'datetime',
        ];
    }

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function conversation(): BelongsTo
    {
        return $this->belongsTo(Conversation::class);
    }
}
```

## 3. Definir o contrato das tools e uma tool de risco

Cada tool declara se exige confirmação e separa três momentos: preparar (validar e normalizar), resumir (texto para o humano) e executar.

```php
<?php

namespace App\Agent\Tools;

use App\Models\Customer;

interface AgentTool
{
    public function name(): string;

    public function definition(): array;

    public function requiresConfirmation(): bool;

    /** Valida e normaliza os argumentos. Lança ToolInputException se inválidos. */
    public function prepare(array $input, Customer $customer): array;

    public function summarize(array $args, Customer $customer): string;

    public function execute(array $args, Customer $customer, string $idempotencyKey): array;
}
```

A tool de estorno (`RefundService` e `isRefundable()` são do seu domínio):

```php
<?php

namespace App\Agent\Tools;

use App\Models\Customer;
use App\Services\RefundService;

final class RefundSaleTool implements AgentTool
{
    public function __construct(private RefundService $refunds) {}

    public function name(): string
    {
        return 'estornar_venda';
    }

    public function definition(): array
    {
        return [
            'name' => $this->name(),
            'description' => 'Solicita o estorno total de uma venda do cliente. NÃO executa na hora: o sistema envia um código e o cliente precisa confirmar.',
            'input_schema' => [
                'type' => 'object',
                'properties' => [
                    'sale_id' => ['type' => 'integer', 'description' => 'ID da venda a estornar'],
                ],
                'required' => ['sale_id'],
            ],
        ];
    }

    public function requiresConfirmation(): bool
    {
        return true;
    }

    public function prepare(array $input, Customer $customer): array
    {
        $sale = $customer->sales()->whereKey($input['sale_id'] ?? 0)->first();

        if (! $sale || ! $sale->isRefundable()) {
            throw new ToolInputException('Venda não encontrada ou não pode ser estornada.');
        }

        return ['sale_id' => $sale->id, 'amount_cents' => $sale->amount_cents];
    }

    public function summarize(array $args, Customer $customer): string
    {
        $sale = $customer->sales()->findOrFail($args['sale_id']);
        $valor = number_format($args['amount_cents'] / 100, 2, ',', '.');

        return "Estornar a venda #{$sale->id} de R$ {$valor} feita em {$sale->created_at->format('d/m/Y H:i')}";
    }

    public function execute(array $args, Customer $customer, string $idempotencyKey): array
    {
        $sale = $customer->sales()->findOrFail($args['sale_id']);

        if (! $sale->isRefundable() || $sale->amount_cents !== $args['amount_cents']) {
            throw new ToolInputException('A venda mudou desde o pedido de confirmação.');
        }

        $refund = $this->refunds->refund($sale, $idempotencyKey);

        return ['refund_id' => $refund->id, 'status' => $refund->status];
    }
}
```

Dois detalhes importam. Primeiro, `prepare` busca a venda **pelo cliente da conversa**, então o modelo não consegue estornar venda de outro lojista nem passando outro ID. Segundo, o valor gravado na pendência é o do banco, não o que o modelo disse. O resumo mostrado ao humano é montado a partir desses dados.

## 4. Transformar a chamada da tool em pendência

O dispatcher é o ponto onde o fluxo se divide. Tool de leitura executa na hora. Tool de risco cria a pendência, manda a mensagem de confirmação direto pelo WhatsApp e devolve ao modelo um `tool_result` deixando claro que nada aconteceu ainda.

```php
<?php

namespace App\Agent;

use App\Agent\Tools\ToolInputException;
use App\Enums\PendingActionStatus;
use App\Models\Conversation;
use App\Models\PendingAction;
use App\Services\WhatsAppClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

final class ToolDispatcher
{
    public function __construct(
        private ToolRegistry $registry,
        private WhatsAppClient $whatsapp,
    ) {}

    public function handle(Conversation $conversation, string $name, array $input): array
    {
        $tool = $this->registry->get($name);
        $customer = $conversation->customer;

        try {
            $args = $tool->prepare($input, $customer);

            if (! $tool->requiresConfirmation()) {
                return $tool->execute($args, $customer, (string) Str::uuid());
            }
        } catch (ToolInputException $e) {
            return ['error' => $e->getMessage()];
        }

        $pending = DB::transaction(function () use ($conversation, $customer, $tool, $args) {
            PendingAction::where('conversation_id', $conversation->id)
                ->where('status', PendingActionStatus::Pending->value)
                ->update(['status' => PendingActionStatus::Cancelled->value]);

            return PendingAction::create([
                'conversation_id' => $conversation->id,
                'customer_id' => $customer->id,
                'tool' => $tool->name(),
                'arguments' => $args,
                'summary' => $tool->summarize($args, $customer),
                'code' => (string) random_int(1000, 9999),
                'status' => PendingActionStatus::Pending,
                'expires_at' => now()->addMinutes(10),
            ]);
        });

        $this->whatsapp->sendText(
            $conversation->phone,
            "Confirma esta operação?\n\n{$pending->summary}\n\nResponda SIM {$pending->code} para confirmar ou NÃO para cancelar. O pedido expira em 10 minutos."
        );

        return [
            'status' => 'aguardando_confirmacao',
            'instrucao' => 'A ação ainda NÃO foi executada. O sistema enviou ao cliente um resumo com código. Não afirme que a operação foi feita.',
        ];
    }
}
```

A mensagem de confirmação sai pelo `WhatsAppClient` e não pela resposta do modelo. Se ela fosse gerada pelo LLM, um resumo errado ("estornar R$ 8,90" quando o valor é R$ 89,00) levaria o cliente a confirmar algo diferente do que vai ser executado.

## 5. Ligar o dispatcher ao loop do agente com a API do Claude

O loop é o de sempre do tool calling. Mostro completo porque tem dois ajustes que fazem diferença aqui.

```php
<?php

namespace App\Agent;

use App\Models\Conversation;
use Illuminate\Support\Facades\Http;

final class AgentTurn
{
    public function __construct(
        private ToolDispatcher $dispatcher,
        private ToolRegistry $registry,
    ) {}

    public function run(Conversation $conversation, array $messages): string
    {
        for ($i = 0; $i < 5; $i++) {
            $response = Http::withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])->timeout(60)->post('https://api.anthropic.com/v1/messages', [
                'model' => config('services.anthropic.model'),
                'max_tokens' => 1024,
                'system' => 'Você é o assistente de atendimento. Ações como estorno e cobrança só acontecem depois que o cliente confirma com o código enviado pelo sistema. Nunca diga que uma ação foi feita se o resultado da ferramenta for aguardando_confirmacao.',
                'tools' => $this->registry->definitions(),
                'tool_choice' => ['type' => 'auto', 'disable_parallel_tool_use' => true],
                'messages' => $messages,
            ])->throw()->json();

            $content = array_map(function (array $block) {
                if ($block['type'] === 'tool_use') {
                    $block['input'] = (object) $block['input'];
                }

                return $block;
            }, $response['content']);

            $messages[] = ['role' => 'assistant', 'content' => $content];

            if ($response['stop_reason'] !== 'tool_use') {
                return collect($response['content'])->where('type', 'text')->pluck('text')->implode("\n");
            }

            $results = [];

            foreach ($response['content'] as $block) {
                if ($block['type'] !== 'tool_use') {
                    continue;
                }

                $output = $this->dispatcher->handle($conversation, $block['name'], $block['input']);

                $results[] = [
                    'type' => 'tool_result',
                    'tool_use_id' => $block['id'],
                    'content' => json_encode($output, JSON_UNESCAPED_UNICODE),
                ];
            }

            $messages[] = ['role' => 'user', 'content' => $results];
        }

        return 'Não consegui concluir agora. Vou te passar para um atendente.';
    }
}
```

No `config/services.php`:

```php
'anthropic' => [
    'key' => env('ANTHROPIC_API_KEY'),
    'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-5'),
],
```

O `disable_parallel_tool_use` impede o modelo de pedir dois estornos na mesma resposta. O cast `(object)` está explicado em "Erros comuns".

## 6. Como validar o "SIM" do cliente sem passar pelo LLM?

A resposta de confirmação é tratada por código comum, antes de qualquer chamada ao modelo. Se não houver pendência aberta, ou se a mensagem não for uma confirmação ou um cancelamento, ela segue para o agente normalmente.

```php
<?php

namespace App\Agent;

use App\Enums\PendingActionStatus;
use App\Jobs\ExecutePendingAction;
use App\Models\Conversation;
use App\Models\PendingAction;
use App\Services\WhatsAppClient;
use Illuminate\Support\Str;

final class ConfirmationHandler
{
    public function __construct(private WhatsAppClient $whatsapp) {}

    public function tryHandle(Conversation $conversation, string $text): bool
    {
        $pending = PendingAction::where('conversation_id', $conversation->id)
            ->where('status', PendingActionStatus::Pending->value)
            ->first();

        if (! $pending) {
            return false;
        }

        $normalized = Str::of($text)->squish()->upper()->toString();

        if (in_array($normalized, ['NAO', 'NÃO', 'CANCELAR'], true)) {
            $pending->update(['status' => PendingActionStatus::Cancelled]);
            $this->whatsapp->sendText($conversation->phone, 'Cancelado. Nada foi alterado.');

            return true;
        }

        if (! preg_match('/^SIM\s+(\d{4})$/u', $normalized, $m)) {
            return false;
        }

        if ($pending->expires_at->isPast()) {
            $pending->update(['status' => PendingActionStatus::Expired]);
            $this->whatsapp->sendText($conversation->phone, 'Esse pedido expirou. Se ainda quiser, é só pedir de novo.');

            return true;
        }

        if (! hash_equals($pending->code, $m[1])) {
            $pending->increment('attempts');

            if ($pending->attempts >= 3) {
                $pending->update(['status' => PendingActionStatus::Cancelled]);
                $this->whatsapp->sendText($conversation->phone, 'Código incorreto três vezes. Cancelei o pedido por segurança.');
            } else {
                $this->whatsapp->sendText($conversation->phone, 'O código não confere. Confira a mensagem de confirmação.');
            }

            return true;
        }

        $claimed = PendingAction::whereKey($pending->id)
            ->where('status', PendingActionStatus::Pending->value)
            ->update([
                'status' => PendingActionStatus::Confirmed->value,
                'confirmed_at' => now(),
            ]);

        if ($claimed === 1) {
            ExecutePendingAction::dispatch($pending->id);
            $this->whatsapp->sendText($conversation->phone, 'Confirmado. Estou processando e te aviso o resultado.');
        }

        return true;
    }
}
```

No controller do webhook do WhatsApp, a interceptação vem primeiro:

```php
public function __invoke(Request $request, ConfirmationHandler $confirmations)
{
    [$conversation, $text] = $this->parseIncoming($request);

    if ($confirmations->tryHandle($conversation, $text)) {
        return response()->noContent();
    }

    ProcessAgentTurn::dispatch($conversation->id, $text);

    return response()->noContent();
}
```

Por que código e não só "SIM"? Porque "sim" aparece em qualquer conversa ("sim, mas antes me diz o saldo"). O código de 4 dígitos amarra a resposta a uma pendência específica e exige um ato deliberado. O `UPDATE ... WHERE status = 'pending'` atômico garante que duas mensagens "SIM 1234" chegando juntas disparem só um job.

## 7. Executar a ação confirmada num job idempotente

*Idempotência* é a propriedade de uma operação produzir o mesmo efeito se for executada uma ou várias vezes. O job usa o status `executing` como trava e repassa ao provedor uma chave estável derivada do ID da pendência.

```bash
php artisan make:job ExecutePendingAction
```

```php
<?php

namespace App\Jobs;

use App\Agent\ToolRegistry;
use App\Enums\PendingActionStatus;
use App\Models\PendingAction;
use App\Services\WhatsAppClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Throwable;

class ExecutePendingAction implements ShouldQueue
{
    use Queueable;

    public int $tries = 1;

    public function __construct(public int $pendingActionId) {}

    public function handle(ToolRegistry $registry, WhatsAppClient $whatsapp): void
    {
        $claimed = PendingAction::whereKey($this->pendingActionId)
            ->where('status', PendingActionStatus::Confirmed->value)
            ->update(['status' => PendingActionStatus::Executing->value]);

        if ($claimed !== 1) {
            return;
        }

        $action = PendingAction::with(['customer', 'conversation'])->findOrFail($this->pendingActionId);
        $tool = $registry->get($action->tool);

        try {
            $result = $tool->execute($action->arguments, $action->customer, "pending-action-{$action->id}");

            $action->update([
                'status' => PendingActionStatus::Executed,
                'result' => $result,
                'executed_at' => now(),
            ]);

            $whatsapp->sendText($action->conversation->phone, "Feito: {$action->summary}.");
        } catch (Throwable $e) {
            $action->update([
                'status' => PendingActionStatus::Failed,
                'result' => ['error' => $e->getMessage()],
            ]);

            report($e);

            $whatsapp->sendText($action->conversation->phone, 'Não consegui concluir a operação agora. Um atendente vai verificar e te retornar.');
        }
    }
}
```

Repare na mensagem de falha: ela não diz "nada foi alterado". Num timeout com o gateway você não sabe se o estorno aconteceu. Por isso a chave `pending-action-{id}` vai para o provedor, e o mesmo raciocínio do post sobre [timeout no gateway e Idempotency-Key](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel) vale aqui: a nova tentativa, manual ou por reconciliação, usa a mesma chave e não duplica.

Depois de executar, registre no histórico da conversa uma mensagem do tipo "[sistema] Ação executada: ..." para o agente saber, no próximo turno, que o estorno já foi feito.

## 8. Expirar pendências antigas com o scheduler

O `ConfirmationHandler` já trata pendência vencida no momento da resposta, mas uma pendência aberta para sempre trava o índice único da conversa. Em `routes/console.php`:

```php
<?php

use App\Enums\PendingActionStatus;
use App\Models\PendingAction;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    PendingAction::where('status', PendingActionStatus::Pending->value)
        ->where('expires_at', '<', now())
        ->update(['status' => PendingActionStatus::Expired->value]);
})->everyMinute()->name('expire-pending-actions')->withoutOverlapping();
```

Aproveite o mesmo agendamento para alertar sobre pendências em `executing` há mais de alguns minutos. É o sinal de worker que morreu no meio da execução.

## 9. Como testar que o agente não executa nada sem confirmação?

O teste mais importante simula o modelo pedindo o estorno e verifica que o estado da venda não mudou até a confirmação.

```php
it('só estorna depois do SIM com código', function () {
    Queue::fake();

    $conversation = Conversation::factory()->create();
    $sale = Sale::factory()->for($conversation->customer)->refundable()->create();

    Http::fake([
        'api.anthropic.com/*' => Http::sequence()
            ->push([
                'stop_reason' => 'tool_use',
                'content' => [[
                    'type' => 'tool_use',
                    'id' => 'toolu_1',
                    'name' => 'estornar_venda',
                    'input' => ['sale_id' => $sale->id],
                ]],
            ])
            ->push([
                'stop_reason' => 'end_turn',
                'content' => [['type' => 'text', 'text' => 'Enviei um código para você confirmar.']],
            ]),
        '*' => Http::response([], 200),
    ]);

    app(AgentTurn::class)->run($conversation, [['role' => 'user', 'content' => 'estorna a última venda']]);

    $pending = PendingAction::sole();
    expect($pending->status)->toBe(PendingActionStatus::Pending);
    expect($sale->fresh()->isRefundable())->toBeTrue();
    Queue::assertNothingPushed();

    app(ConfirmationHandler::class)->tryHandle($conversation, "sim {$pending->code}");

    expect($pending->fresh()->status)->toBe(PendingActionStatus::Confirmed);
    Queue::assertPushed(ExecutePendingAction::class);
});
```

Vale ter também os casos de código errado três vezes, pendência expirada e duas confirmações seguidas (a segunda não pode despachar outro job).

## Erros comuns

1. **O agente responde "pronto, estornei" antes da confirmação.** Acontece quando o `tool_result` é vago, tipo `{"ok": true}`. Devolva um status explícito (`aguardando_confirmacao`) com instrução, e reforce no system prompt. Mesmo assim, a mensagem que vale é a do sistema, com o código.
2. **Erro 400 da API ao reenviar o histórico com tool sem parâmetros.** O `->json()` do Laravel decodifica `"input": {}` como array vazio, e ao reenviar ele vira `[]`, que a API rejeita porque espera um objeto. Por isso o cast `(object) $block['input']` no passo 5.
3. **Dois estornos pedidos na mesma resposta.** Sem `disable_parallel_tool_use`, o modelo pode emitir dois `tool_use` de risco e o segundo cancela o primeiro silenciosamente. Desligue o paralelismo ou recuse a segunda pendência com erro no `tool_result`.
4. **Confirmação atrasada pelo agrupamento de mensagens.** Se você usa debounce para juntar mensagens picadas antes do agente, intercepte a confirmação antes do debounce. Senão o "SIM 1234" espera segundos à toa ou vai parar no LLM junto com outra frase.
5. **Dado mudou entre o pedido e a confirmação.** A venda foi estornada pelo painel nesse meio tempo, ou o valor foi ajustado. O `execute` precisa revalidar tudo e falhar se o estado divergir do que foi mostrado ao cliente.
6. **Pendência presa em `executing`.** O worker morreu durante a chamada ao gateway. Não reprocesse automaticamente sem a mesma chave de idempotência. Consulte o provedor e reconcilie.

## Perguntas frequentes

### Posso usar botões interativos do WhatsApp em vez do código digitado?

Pode, e a experiência fica melhor. Coloque o ID da pendência e o código no payload do botão e valide tudo no servidor, exatamente como no `ConfirmationHandler`. O botão substitui a digitação, não a validação.

### Toda tool de escrita precisa de confirmação?

Não. Ações baratas e reversíveis, como remarcar um lembrete, podem executar direto. Peça confirmação quando a ação mexe em dinheiro, afeta terceiros ou não tem desfazer simples.

### O modelo pode cancelar uma pendência?

Eu não deixo. Se o cliente mudar de ideia no meio da conversa, o agente cria uma nova pendência, que cancela a anterior no próprio dispatcher. Cancelamento explícito fica com o "NÃO" do usuário ou com a expiração.

### Isso funciona com OpenAI ou Gemini?

Funciona. O padrão vive no dispatcher, na tabela e no handler, que não dependem do provedor. Só muda o formato dos blocos de tool call e de resultado no loop do passo 5.

### Quanto tempo deixar a pendência aberta?

Entre 5 e 15 minutos cobre o cliente que foi conferir o extrato antes de confirmar. Mais que isso aumenta a chance de o estado mudar e de alguém confirmar algo de que já não lembra.

## Conclusão

O ponto central é tirar a decisão do modelo e colocar no código. O LLM continua útil para entender o pedido e escolher a ação. Quem monta o resumo, valida o código, trava a execução e fala com o gateway é o Laravel, com regras determinísticas e testáveis. Se for implementar só uma parte hoje, comece pela tabela com índice único parcial e pela interceptação da confirmação antes do agente. Essas duas peças já impedem o pior cenário, que é o modelo estornar dinheiro porque leu algo que não devia.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
