Para transferir o atendimento de um agente de IA para um humano no WhatsApp com Laravel, você precisa de três peças: um status na conversa que diga quem está respondendo (bot, aguardando humano, humano, encerrada), gatilhos de transferência (regras fixas no código e uma tool que o próprio Claude pode chamar) e um roteador que pare de chamar o LLM enquanto um atendente estiver na conversa. O resto é acabamento: um resumo estruturado para o atendente não pedir tudo de novo ao cliente e um comando agendado que devolve conversas paradas para o bot.

O handoff (passagem da conversa do bot para uma pessoa) é a parte que mais pesa na percepção do cliente. Em agendamento, um agente que insiste em responder quando o cliente já pediu um atendente irrita. Em fintech é pior: contestação de cobrança, estorno e suspeita de fraude não podem ficar na mão de um LLM. Eu trato isso como regra de produto, não como sugestão no prompt.

O que você vai ter funcionando no final

  1. Uma conversa com status explícito e transições validadas.
  2. Regras determinísticas que transferem antes de gastar uma chamada ao LLM.
  3. Uma tool transferir_para_atendente que o Claude chama quando percebe que não deve continuar.
  4. Um resumo em JSON gerado pelo Claude para o atendente, com saída forçada via tool_choice.
  5. Bot silencioso enquanto o humano atende, e volta automática ao bot depois de inatividade.

Pré-requisitos: Laravel 11 ou 12, PHP 8.2+, PostgreSQL, fila no Redis (com Horizon ou queue:work), uma chave da API da Anthropic e um agente de IA já recebendo mensagens do WhatsApp. Se você ainda não tem o agente, comece pelo post sobre como criar um agente de IA com tool calling do Claude no Laravel para agendamentos no WhatsApp; aqui eu parto de uma tabela conversations e de uma tabela messages que já existem.

No .env:

ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
ANTHROPIC_SUMMARY_MODEL=claude-haiku-4-5

Em config/services.php:

'anthropic' => [
    'key' => env('ANTHROPIC_API_KEY'),
    'model' => env('ANTHROPIC_MODEL'),
    'summary_model' => env('ANTHROPIC_SUMMARY_MODEL'),
],

1. Como modelar o status da conversa entre bot e humano?

O erro clássico é usar um booleano bot_ativo. Ele não diferencia "o cliente pediu humano e ninguém pegou ainda" de "um atendente está respondendo agora", e essa diferença muda o que o sistema faz com cada mensagem nova.

php artisan make:migration add_handoff_columns_to_conversations
public function up(): void
{
    Schema::table('conversations', function (Blueprint $table) {
        $table->string('status')->default('bot')->index();
        $table->string('handoff_reason')->nullable();
        $table->jsonb('handoff_summary')->nullable();
        $table->foreignId('assigned_user_id')->nullable()->constrained('users');
        $table->timestamp('handoff_at')->nullable();
        $table->timestamp('last_human_reply_at')->nullable();
        $table->unsignedTinyInteger('bot_failures')->default(0);
    });
}

O enum guarda as transições permitidas. É a mesma ideia que uso para status de pagamento no post de máquina de estados no Laravel para impedir transições inválidas, em versão enxuta:

enum ConversationStatus: string
{
    case Bot = 'bot';
    case WaitingHuman = 'aguardando_humano';
    case Human = 'humano';
    case Closed = 'encerrada';

    public function canTransitionTo(self $to): bool
    {
        return in_array($to, match ($this) {
            self::Bot => [self::WaitingHuman, self::Closed],
            self::WaitingHuman => [self::Human, self::Bot, self::Closed],
            self::Human => [self::Bot, self::Closed],
            self::Closed => [self::Bot],
        }, true);
    }

    public function botCanReply(): bool
    {
        return $this === self::Bot;
    }
}

E os motivos de transferência, que vão aparecer tanto nas regras quanto no schema da tool:

enum HandoffReason: string
{
    case CustomerAsked = 'pedido_cliente';
    case SensitiveTopic = 'tema_sensivel';
    case RepeatedFailures = 'falhas_repetidas';
    case OutOfScope = 'fora_do_escopo';
    case AngryCustomer = 'cliente_irritado';
}

No model:

protected function casts(): array
{
    return [
        'status' => ConversationStatus::class,
        'handoff_reason' => HandoffReason::class,
        'handoff_summary' => 'array',
        'handoff_at' => 'datetime',
        'last_human_reply_at' => 'datetime',
    ];
}

2. Quando transferir sem perguntar ao LLM?

Tem coisa que eu não deixo para o modelo decidir. Se o cliente escreveu "quero falar com um atendente", não faz sentido pagar uma chamada ao Claude para ele concluir o óbvio. E se o assunto é contestação ou fraude, a transferência é obrigatória, independente do que o modelo acharia.

use Illuminate\Support\Str;

final class HandoffRules
{
    private const CUSTOMER_ASKED = [
        'atendente', 'falar com alguem', 'pessoa de verdade', 'falar com humano',
    ];

    private const SENSITIVE = [
        'contestar', 'contestacao', 'estorno', 'nao reconheco',
        'fraude', 'golpe', 'chargeback', 'clonaram',
    ];

    public function check(Conversation $conversation, string $text): ?HandoffReason
    {
        $normalized = Str::lower(Str::ascii($text));

        if (Str::contains($normalized, self::SENSITIVE)) {
            return HandoffReason::SensitiveTopic;
        }

        if (Str::contains($normalized, self::CUSTOMER_ASKED)) {
            return HandoffReason::CustomerAsked;
        }

        if ($conversation->bot_failures >= 3) {
            return HandoffReason::RepeatedFailures;
        }

        return null;
    }
}

Str::ascii remove acentos, então as listas ficam sem acento e você pega "contestação" e "contestacao" com a mesma regra. O bot_failures é incrementado pelo agente quando uma tool de negócio falha ou quando ele responde que não conseguiu entender; três falhas seguidas é o meu ponto de corte padrão, ajuste ao seu caso.

O trade-off é falso positivo: "não reconheço esse horário" vai para humano. Eu aceito isso nos temas sensíveis, porque o custo de um atendente ler uma conversa à toa é muito menor que o de um bot tratando mal uma contestação.

3. Como deixar o Claude pedir a transferência com tool calling?

Tool calling é o recurso em que você descreve funções com um JSON Schema e o modelo devolve um bloco tool_use pedindo para executá-las. As regras fixas pegam o óbvio; a tool pega o resto: cliente irritado, pedido que nenhuma outra ferramenta resolve, conversa andando em círculo.

Adicione a tool à lista que o agente já envia:

[
    'name' => 'transferir_para_atendente',
    'description' => 'Transfere a conversa para um atendente humano. Use quando o cliente pedir, '
        . 'quando o assunto envolver contestação de cobrança, estorno ou fraude, quando nenhuma '
        . 'outra ferramenta resolver o pedido, ou quando o cliente demonstrar irritação. '
        . 'Depois de chamar, não tente resolver o assunto.',
    'input_schema' => [
        'type' => 'object',
        'properties' => [
            'motivo' => [
                'type' => 'string',
                'enum' => array_column(HandoffReason::cases(), 'value'),
            ],
            'observacao' => [
                'type' => 'string',
                'description' => 'Uma frase objetiva para o atendente.',
            ],
        ],
        'required' => ['motivo'],
    ],
],

No loop do agente, essa tool é diferente das outras: ela encerra o turno. Não devolvo o resultado para o modelo continuar a conversa, porque a partir dali quem fala é o humano.

foreach ($response->json('content') as $block) {
    if ($block['type'] !== 'tool_use' || $block['name'] !== 'transferir_para_atendente') {
        continue;
    }

    $reason = HandoffReason::tryFrom($block['input']['motivo'] ?? '')
        ?? HandoffReason::OutOfScope;

    // Fecha o par tool_use/tool_result no histórico salvo (veja Erros comuns)
    $this->history->appendToolResult($conversation, $block['id'], 'Conversa transferida.');

    $this->handoff->requestHuman($conversation, $reason, $block['input']['observacao'] ?? null);

    return;
}

O tryFrom com fallback existe porque, mesmo com enum no schema, eu não confio cegamente no que vem do modelo. Valor fora da lista vira fora_do_escopo em vez de exceção.

4. Como fazer a transferência sem duplicar notificação?

O webhook do WhatsApp pode entregar duas mensagens quase juntas, e as duas podem disparar handoff. Sem lock, a equipe recebe dois alertas e o resumo é gerado duas vezes. O HandoffService trava a linha da conversa, valida a transição e só agenda o job depois do commit:

use Illuminate\Support\Facades\DB;

final class HandoffService
{
    public function __construct(private WhatsAppSender $whatsapp) {}

    public function requestHuman(Conversation $conversation, HandoffReason $reason, ?string $note = null): bool
    {
        $changed = DB::transaction(function () use ($conversation, $reason, $note) {
            $locked = Conversation::whereKey($conversation->id)->lockForUpdate()->firstOrFail();

            if (! $locked->status->canTransitionTo(ConversationStatus::WaitingHuman)) {
                return false;
            }

            $locked->update([
                'status' => ConversationStatus::WaitingHuman,
                'handoff_reason' => $reason,
                'handoff_summary' => $note ? ['observacao_agente' => $note] : null,
                'handoff_at' => now(),
            ]);

            GenerateHandoffSummary::dispatch($locked->id)->afterCommit();

            return true;
        });

        if ($changed) {
            $this->whatsapp->sendText(
                $conversation->phone,
                'Vou te passar para alguém do nosso time. Já deixei o resumo da conversa, não precisa repetir.'
            );
        }

        return $changed;
    }
}

O afterCommit() não é detalhe: sem ele, o worker pode pegar o job antes do commit e ler a conversa ainda como bot. Expliquei esse bug em detalhe no post sobre jobs disparados dentro de transação e o afterCommit no Laravel. WhatsAppSender é a interface que você já usa para enviar mensagens pelo seu provedor.

5. Como gerar um resumo estruturado para o atendente?

O atendente abre a conversa e precisa entender em 10 segundos o que está acontecendo. Peço ao Claude um resumo em JSON e forço o formato com tool_choice, que obriga o modelo a responder chamando uma tool específica. Para resumo, um modelo menor como o Haiku resolve bem e custa menos.

php artisan make:job GenerateHandoffSummary
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;

final class GenerateHandoffSummary implements ShouldQueue
{
    use Queueable;

    public int $tries = 2;
    public int $timeout = 45;

    public function __construct(public int $conversationId) {}

    public function handle(AttendantNotifier $notifier): void
    {
        $conversation = Conversation::findOrFail($this->conversationId);

        $transcript = $conversation->messages()->latest()->limit(30)->get()->reverse()
            ->map(fn ($m) => "{$m->role}: {$m->content}")
            ->implode("\n");

        $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
        ])->timeout(30)->post('https://api.anthropic.com/v1/messages', [
            'model' => config('services.anthropic.summary_model'),
            'max_tokens' => 600,
            'system' => 'Você resume conversas de atendimento para um atendente humano. '
                . 'Seja factual, não invente dados que não estão na conversa.',
            'tools' => [[
                'name' => 'registrar_resumo',
                'description' => 'Registra o resumo da conversa para o atendente.',
                'input_schema' => [
                    'type' => 'object',
                    'properties' => [
                        'pedido' => ['type' => 'string', 'description' => 'O que o cliente quer, em uma frase.'],
                        'ja_tentado' => ['type' => 'array', 'items' => ['type' => 'string']],
                        'dados_citados' => ['type' => 'array', 'items' => ['type' => 'string']],
                        'humor' => ['type' => 'string', 'enum' => ['calmo', 'impaciente', 'irritado']],
                        'proximo_passo' => ['type' => 'string'],
                    ],
                    'required' => ['pedido', 'humor', 'proximo_passo'],
                ],
            ]],
            'tool_choice' => ['type' => 'tool', 'name' => 'registrar_resumo'],
            'messages' => [['role' => 'user', 'content' => $transcript]],
        ])->throw();

        $summary = collect($response->json('content'))->firstWhere('type', 'tool_use')['input'] ?? [];

        $conversation->update([
            'handoff_summary' => array_merge($conversation->handoff_summary ?? [], $summary),
        ]);

        $notifier->handoffRequested($conversation);
    }

    public function failed(\Throwable $e): void
    {
        // O resumo é acessório: a equipe precisa ser avisada mesmo sem ele
        app(AttendantNotifier::class)->handoffRequested(Conversation::find($this->conversationId));
    }
}

O trade-off aqui: com o aviso dentro do job, a equipe só é notificada depois do resumo, alguns segundos mais tarde. Se o seu SLA for apertado, separe em dois jobs: um notifica na hora e o outro atualiza o painel quando o resumo chegar. O AttendantNotifier é onde você manda o alerta (Slack, e-mail, broadcast para o painel interno).

6. Como impedir o bot de responder enquanto o humano atende?

Esse é o ponto que mais vejo dar errado: a conversa vai para o humano, o cliente manda "oi?" e o bot responde por cima do atendente. O roteador decide antes de qualquer chamada ao LLM:

final class IncomingMessageRouter
{
    public function __construct(
        private HandoffRules $rules,
        private HandoffService $handoff,
        private SupportAgent $agent,
        private AttendantNotifier $notifier,
    ) {}

    public function handle(Conversation $conversation, string $text): void
    {
        if ($conversation->status === ConversationStatus::Closed) {
            $conversation->update(['status' => ConversationStatus::Bot, 'bot_failures' => 0]);
        }

        if (! $conversation->status->botCanReply()) {
            $this->notifier->newCustomerMessage($conversation, $text);
            return;
        }

        if ($reason = $this->rules->check($conversation, $text)) {
            $this->handoff->requestHuman($conversation, $reason);
            return;
        }

        $this->agent->reply($conversation, $text);
    }
}

No painel, o atendente assume a conversa. O lock evita que dois atendentes peguem a mesma:

public function claim(Conversation $conversation, Request $request)
{
    $claimed = DB::transaction(function () use ($conversation, $request) {
        $locked = Conversation::whereKey($conversation->id)->lockForUpdate()->firstOrFail();

        if ($locked->status !== ConversationStatus::WaitingHuman) {
            return false;
        }

        $locked->update([
            'status' => ConversationStatus::Human,
            'assigned_user_id' => $request->user()->id,
        ]);

        return true;
    });

    abort_unless($claimed, 409, 'Conversa já assumida por outro atendente.');

    return back();
}

Toda resposta enviada pelo atendente atualiza last_human_reply_at. É esse campo que o próximo passo usa.

7. Como devolver a conversa para o bot automaticamente?

Atendente fecha a aba e esquece a conversa como humano. Dias depois o cliente volta e fica falando sozinho, porque o bot está calado. Um comando agendado resolve os dois casos: conversa com humano inativa e conversa esperando na fila há tempo demais.

php artisan make:command ReleaseStaleConversations
final class ReleaseStaleConversations extends Command
{
    protected $signature = 'conversations:release-stale';

    public function handle(WhatsAppSender $whatsapp): void
    {
        Conversation::where('status', ConversationStatus::Human)
            ->where('last_human_reply_at', '<', now()->subMinutes(30))
            ->each(fn (Conversation $c) => $c->update([
                'status' => ConversationStatus::Closed,
                'assigned_user_id' => null,
            ]));

        Conversation::where('status', ConversationStatus::WaitingHuman)
            ->where('handoff_at', '<', now()->subMinutes(15))
            ->whereNull('assigned_user_id')
            ->each(function (Conversation $c) use ($whatsapp) {
                $whatsapp->sendText($c->phone, 'Nosso time ainda não conseguiu responder. Seu pedido está registrado e retornamos assim que possível.');
                $c->update(['handoff_at' => now()]);
            });
    }
}

Em routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('conversations:release-stale')
    ->everyFiveMinutes()
    ->withoutOverlapping();

Repare que a conversa inativa vai para encerrada, não direto para bot. Na próxima mensagem o roteador reabre com o bot e zera bot_failures. Atualizar handoff_at na fila evita mandar o aviso de espera a cada cinco minutos.

8. Como testar o handoff sem chamar a API?

O teste mais importante é provar que, nos casos de transferência, nenhuma chamada ao LLM sai:

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;

it('transfere contestação sem chamar o LLM', function () {
    Http::fake();
    Queue::fake();
    $conversation = Conversation::factory()->create(['status' => ConversationStatus::Bot]);

    app(IncomingMessageRouter::class)->handle($conversation, 'Quero contestar uma cobrança que não reconheço');

    expect($conversation->fresh()->status)->toBe(ConversationStatus::WaitingHuman);
    Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.anthropic.com'));
    Queue::assertPushed(GenerateHandoffSummary::class);
});

it('não responde com o bot quando um humano está atendendo', function () {
    Http::fake();
    $conversation = Conversation::factory()->create(['status' => ConversationStatus::Human]);

    app(IncomingMessageRouter::class)->handle($conversation, 'oi?');

    Http::assertNotSent(fn ($request) => str_contains($request->url(), 'api.anthropic.com'));
});

No ambiente de teste, deixe o WhatsAppSender com um fake no container para não enviar mensagem de verdade.

Erros comuns

  1. Erro 400 da API na próxima chamada depois do handoff. Se você salvou no histórico o bloco tool_use de transferir_para_atendente e não salvou o tool_result correspondente, a API rejeita a requisição quando a conversa voltar para o bot. Sempre feche o par com um tool_result curto, como no passo 3, ou não salve esse turno do assistente.
  2. Bot e atendente respondendo ao mesmo tempo. Geralmente o job do agente já estava na fila quando a conversa mudou de status. Revalide botCanReply() dentro do job, logo antes de enviar a resposta, e não só no roteador.
  3. Regra de palavra-chave transferindo demais. "Humano" sozinho pega frases que não têm nada a ver com pedir atendente. Prefira expressões ("falar com humano") e revise os handoffs por motivo uma vez por semana para ajustar a lista.
  4. Modelo resolvendo o assunto e transferindo depois. Se o Claude responde com texto e chama a tool no mesmo turno, o cliente recebe uma resposta pela metade. Deixe explícito na descrição da tool que, depois de chamá-la, ele não deve tentar resolver, e descarte o texto que vier junto quando houver handoff.
  5. Resumo com dados inventados. Com conversa curta ou confusa, o modelo tende a preencher lacunas. O system prompt pedindo factualidade ajuda, e só pedido, humor e proximo_passo são obrigatórios no schema justamente para ele não ser forçado a inventar o resto.

Perguntas frequentes

Preciso de regras fixas se o Claude já tem a tool de transferência?

Sim. As regras cobrem o que não pode depender de interpretação, como contestação e fraude, e economizam uma chamada ao LLM quando o pedido é óbvio. A tool cobre os casos sutis.

Qual modelo usar para o resumo do handoff?

Um modelo menor, como o Claude Haiku, costuma ser suficiente para resumir 30 mensagens em JSON. Deixe o modelo maior para o agente que conversa e decide.

O bot deve continuar lendo as mensagens enquanto o humano atende?

Ler e salvar, sim, para o histórico ficar completo quando a conversa voltar. Responder, não. O LLM não deve ser chamado enquanto o status for aguardando_humano ou humano.

E fora do horário comercial?

Transfira do mesmo jeito, mas troque a mensagem ao cliente para avisar quando o time volta. O comando agendado do passo 7 continua lembrando o cliente de que o pedido está registrado.

Como saber se o handoff está funcionando bem?

Conte transferências por handoff_reason e o tempo entre handoff_at e a primeira resposta humana. Muito falhas_repetidas indica tool faltando no agente; muito pedido_cliente logo na primeira mensagem indica que o bot está assustando o cliente.

Minha recomendação prática: implemente primeiro o status e o roteador do passo 6, mesmo antes da tool e do resumo. Só garantir que o bot nunca responde por cima de um atendente já resolve a maior parte das reclamações. Depois adicione as regras fixas dos temas sensíveis, e só então refine com a tool e o resumo estruturado.