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
- Uma conversa com status explícito e transições validadas.
- Regras determinísticas que transferem antes de gastar uma chamada ao LLM.
- Uma tool
transferir_para_atendenteque o Claude chama quando percebe que não deve continuar. - Um resumo em JSON gerado pelo Claude para o atendente, com saída forçada via
tool_choice. - 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
- Erro 400 da API na próxima chamada depois do handoff. Se você salvou no histórico o bloco
tool_usedetransferir_para_atendentee não salvou otool_resultcorrespondente, a API rejeita a requisição quando a conversa voltar para o bot. Sempre feche o par com umtool_resultcurto, como no passo 3, ou não salve esse turno do assistente. - 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. - 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.
- 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.
- Resumo com dados inventados. Com conversa curta ou confusa, o modelo tende a preencher lacunas. O system prompt pedindo factualidade ajuda, e só
pedido,humoreproximo_passosã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.