Para criar um agente de IA que consulta e marca horários pelo WhatsApp no Laravel, você expõe funções do seu sistema como ferramentas para o Claude, deixa o modelo decidir quando chamá-las e executa cada chamada no seu código. O controle continua com você: quem é o cliente, o que ele pode fazer e quais dados entram e saem. O loop é simples: mandar a mensagem, executar as ferramentas pedidas, devolver o resultado e repetir até o modelo responder em texto.
No fim deste tutorial você vai ter:
- um
ClaudeClientque chama a Messages API da Anthropic viaHttp::, com timeout e retry; - duas ferramentas (
listar_horarioseagendar_horario) com validação própria; - um loop de agente com limite de iterações;
- um job em fila que processa a mensagem do WhatsApp e responde;
- um teste automatizado sem gastar token.
O cenário é o de um sistema de agendamento como o Horalis, mas o padrão serve para consultar pedidos, cobranças ou saldo.
Pré-requisitos:
- PHP 8.2+ e Laravel 11 ou 12.
- Uma chave da API da Anthropic (console da Anthropic).
- Fila configurada (Redis + Horizon é o que eu uso, mas
databasefunciona). - Um model
Clienteidentificado pelo telefone e uma regra de agenda já existente. Aqui ela aparece comoAgendaService; troque pela sua.
1. Como configurar a API do Claude no Laravel?
Não uso SDK aqui de propósito: a Messages API é um único endpoint HTTP, e com o Http:: do Laravel eu controlo timeout, retry e logs sem depender de pacote de terceiros.
ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-5
Confira o ID do modelo na documentação da Anthropic antes de subir; os IDs mudam com as versões.
// config/services.php 'anthropic' => [ 'key' => env('ANTHROPIC_API_KEY'), 'model' => env('ANTHROPIC_MODEL'), ],
2. Como chamar a Messages API com timeout e retry?
<?php namespace App\Ai; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Throwable; class ClaudeClient { public function messages(array $payload): array { return Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->acceptJson() ->timeout(60) ->retry([1000, 3000], when: function (Throwable $e) { if ($e instanceof ConnectionException) { return true; } return $e instanceof RequestException && in_array($e->response->status(), [429, 500, 529]); }) ->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.model'), 'max_tokens' => 1024, ...$payload, ]) ->json(); } }
Só repito em erro de conexão, 429 (rate limit), 500 e 529 (API sobrecarregada). Um 400 significa payload errado, e repetir só gasta tempo. Com retry(), a última falha vira RequestException e o job decide o que fazer.
3. Como definir ferramentas (tool calling) para o agente?
Tool calling é quando o modelo, em vez de responder em texto, devolve um bloco tool_use com o nome de uma ferramenta e um JSON de entrada que segue o input_schema que você declarou. Quem executa é o seu código.
A decisão de segurança mais importante deste post: o cliente nunca vem do input do modelo. Ele vem do contexto (o telefone que mandou a mensagem) e é passado para a ferramenta pelo código. Assim, um "agende no nome do cliente 42" escrito na conversa não tem efeito nenhum.
<?php namespace App\Ai\Tools; use App\Models\Cliente; interface AgentTool { public function name(): string; /** Retorna name, description e input_schema. */ public function definition(): array; public function handle(array $input, Cliente $cliente): array; }
<?php namespace App\Ai\Tools; use App\Models\Cliente; use App\Services\AgendaService; use Illuminate\Support\Facades\Validator; class ListarHorarios implements AgentTool { public function __construct(private AgendaService $agenda) {} public function name(): string { return 'listar_horarios'; } public function definition(): array { return [ 'name' => $this->name(), 'description' => 'Lista horários livres de um dia. Use antes de sugerir ou marcar qualquer horário.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'data' => ['type' => 'string', 'description' => 'Data no formato YYYY-MM-DD'], ], 'required' => ['data'], ], ]; } public function handle(array $input, Cliente $cliente): array { $dados = Validator::make($input, [ 'data' => ['required', 'date_format:Y-m-d', 'after_or_equal:today'], ])->validate(); return [ 'data' => $dados['data'], 'horarios' => $this->agenda->horariosLivres($cliente->empresa_id, $dados['data']), ]; } }
<?php namespace App\Ai\Tools; use App\Models\Cliente; use App\Services\AgendaService; use DomainException; use Illuminate\Support\Facades\Validator; class AgendarHorario implements AgentTool { public function __construct(private AgendaService $agenda) {} public function name(): string { return 'agendar_horario'; } public function definition(): array { return [ 'name' => $this->name(), 'description' => 'Marca um horário para o cliente atual. Só chame depois que o cliente confirmar data e hora explicitamente.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'inicio' => ['type' => 'string', 'description' => 'Data e hora no formato YYYY-MM-DD HH:MM'], ], 'required' => ['inicio'], ], ]; } public function handle(array $input, Cliente $cliente): array { $dados = Validator::make($input, [ 'inicio' => ['required', 'date_format:Y-m-d H:i', 'after:now'], ])->validate(); $agendamento = $this->agenda->agendar($cliente, $dados['inicio']); if (! $agendamento) { throw new DomainException('Horário indisponível. Liste os horários de novo.'); } return ['agendamento_id' => $agendamento->id, 'inicio' => $dados['inicio']]; } }
A verificação de conflito fica dentro do AgendaService, com lock ou constraint no banco. O modelo pode ter visto o horário livre há 30 segundos; quem garante a consistência é o banco, não o LLM.
4. Como implementar o loop do agente de IA?
<?php namespace App\Ai; use App\Ai\Tools\AgentTool; use App\Models\Cliente; use DomainException; use Illuminate\Support\Collection; use Illuminate\Validation\ValidationException; class AgendaAgent { private const MAX_ITERACOES = 5; /** @param AgentTool[] $tools */ public function __construct(private ClaudeClient $claude, private array $tools) {} public function responder(Cliente $cliente, array $messages): string { $tools = collect($this->tools)->keyBy(fn (AgentTool $t) => $t->name()); for ($i = 0; $i < self::MAX_ITERACOES; $i++) { $response = $this->claude->messages([ 'system' => $this->system($cliente), 'tools' => $tools->map(fn (AgentTool $t) => $t->definition())->values()->all(), 'messages' => $messages, ]); $content = $this->normalizar($response['content']); $messages[] = ['role' => 'assistant', 'content' => $content]; if ($response['stop_reason'] !== 'tool_use') { return collect($content)->where('type', 'text')->pluck('text')->implode("\n"); } $messages[] = ['role' => 'user', 'content' => $this->executar($content, $tools, $cliente)]; } return 'Não consegui concluir agora. Um atendente vai te responder em breve.'; } private function executar(array $content, Collection $tools, Cliente $cliente): array { $resultados = []; foreach ($content as $block) { if ($block['type'] !== 'tool_use') { continue; } $resultado = ['type' => 'tool_result', 'tool_use_id' => $block['id']]; try { $tool = $tools->get($block['name']) ?? throw new DomainException('Ferramenta inexistente.'); $saida = $tool->handle((array) $block['input'], $cliente); $resultado['content'] = json_encode($saida, JSON_UNESCAPED_UNICODE); } catch (ValidationException $e) { $resultado['content'] = implode(' ', $e->validator->errors()->all()); $resultado['is_error'] = true; } catch (DomainException $e) { $resultado['content'] = $e->getMessage(); $resultado['is_error'] = true; } $resultados[] = $resultado; } return $resultados; } /** O JSON {} vira [] no PHP; a API exige objeto em tool_use.input. */ private function normalizar(array $content): array { return array_map(function (array $block) { if ($block['type'] === 'tool_use') { $block['input'] = (object) $block['input']; } return $block; }, $content); } private function system(Cliente $cliente): string { $agora = now('America/Sao_Paulo')->format('Y-m-d H:i (l)'); return <<<TXT Você é o assistente de agendamento no WhatsApp. Agora: {$agora}. Cliente: {$cliente->nome}. Regras: nunca invente horários, sempre use listar_horarios antes. Antes de agendar_horario, peça confirmação explícita de data e hora. Responda em português, em mensagens curtas. TXT; } }
Três detalhes que importam:
- Reenvio o
contentinteiro do assistant, não só o texto. A API exige que cadatool_resultaponte para umtool_usepresente na mensagem anterior. - Erros de validação e de negócio voltam como
tool_resultcomis_error: true. O modelo lê a mensagem, corrige (pede outra data, lista de novo) e segue. Exceções inesperadas eu deixo estourar: o job registra e o cliente recebe a mensagem de fallback. - A data atual vai no system prompt. Sem isso, "amanhã" vira qualquer dia.
Registre o agente no container:
// AppServiceProvider::register() $this->app->bind(AgendaAgent::class, fn ($app) => new AgendaAgent( $app->make(ClaudeClient::class), [$app->make(ListarHorarios::class), $app->make(AgendarHorario::class)], ));
5. Como processar a mensagem do WhatsApp em fila?
O webhook do WhatsApp precisa receber 200 rápido; uma rodada de agente com duas ou três chamadas ao Claude passa fácil de 10 segundos. Então o controller só despacha o job.
Para o histórico, tomei uma decisão de trade-off: guardo apenas os turnos de texto (mensagem do cliente e resposta final), não os blocos de ferramenta. Fica mais barato em tokens e eu nunca corro o risco de cortar o histórico entre um tool_use e seu tool_result. O custo é o modelo às vezes repetir uma consulta à agenda, o que é barato e até desejável, porque a disponibilidade muda.
<?php namespace App\Jobs; use App\Ai\AgendaAgent; use App\Contracts\WhatsAppGateway; use App\Models\Cliente; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\Cache; class ProcessarMensagemWhatsApp implements ShouldQueue { use Queueable; public int $timeout = 150; public int $tries = 1; public function __construct(public int $clienteId, public string $texto) {} public function handle(AgendaAgent $agent, WhatsAppGateway $whatsapp): void { $cliente = Cliente::findOrFail($this->clienteId); $chave = "conversa:{$cliente->id}"; $historico = Cache::get($chave, []); $historico[] = ['role' => 'user', 'content' => $this->texto]; $resposta = $agent->responder($cliente, $historico); $historico[] = ['role' => 'assistant', 'content' => $resposta]; Cache::put($chave, array_slice($historico, -20), now()->addHours(2)); $whatsapp->enviarTexto($cliente->telefone, $resposta); } }
$tries = 1 é proposital: o agente executa ações. Se o job rodasse de novo depois de agendar_horario, o cliente poderia ganhar dois horários. Retentativa fica no nível do HTTP (passo 2), onde nada foi executado ainda. O array_slice com número par mantém o histórico começando por user, que é o que a API espera.
6. Como testar o agente sem chamar a API de verdade?
use App\Ai\AgendaAgent; use App\Models\Cliente; use Illuminate\Support\Facades\Http; it('agenda quando o modelo chama a ferramenta', function () { $cliente = Cliente::factory()->create(); $inicio = now()->addDay()->setTime(10, 0)->format('Y-m-d H:i'); Http::fake([ 'api.anthropic.com/*' => Http::sequence() ->push([ 'stop_reason' => 'tool_use', 'content' => [[ 'type' => 'tool_use', 'id' => 'toolu_1', 'name' => 'agendar_horario', 'input' => ['inicio' => $inicio], ]], ]) ->push([ 'stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'Pronto, agendado!']], ]), ]); $resposta = app(AgendaAgent::class)->responder($cliente, [ ['role' => 'user', 'content' => 'Pode marcar amanhã às 10h, confirmo'], ]); expect($resposta)->toBe('Pronto, agendado!'); expect($cliente->agendamentos()->count())->toBe(1); Http::assertSentCount(2); });
Esse teste não avalia se o modelo toma a decisão certa; ele garante que o seu lado do contrato funciona: execução, permissão, formato do tool_result. A qualidade das decisões se mede com evals rodando contra a API real, separado da suíte de CI.
Erros comuns
- 400 com "Input should be a valid dictionary": uma ferramenta chamada sem argumentos volta com
input: {}, ojson()converte para array vazio e ojson_encodereenvia[]. Por isso onormalizar()faz cast paraobject. O mesmo vale parapropertiesvazio noinput_schema. - 400 dizendo que
tool_usenão temtool_resultcorrespondente: você respondeu só uma das ferramentas quando o modelo pediu duas em paralelo, ou mandou texto antes dos resultados. Toda chamada precisa de resultado, na mensagemuserseguinte. - Agente em loop: a ferramenta falha sempre do mesmo jeito e o modelo tenta de novo. O
MAX_ITERACOEScorta isso; mensagens de erro claras ("formato esperado YYYY-MM-DD") resolvem a maioria dos casos. - Job morto no meio da conversa:
$timeoutdo job menor que a soma das chamadas HTTP. Com timeout de 60s e até 5 iterações, dimensione o job e oretry_afterda conexão de fila acima disso. - Mensagem do WhatsApp processada duas vezes: o provedor reenvia o webhook e o agente responde duas vezes. Deduplique pelo ID da mensagem antes de despachar o job.
- Datas erradas: servidor em UTC e system prompt sem fuso. Sempre passe a data local e o fuso.
Perguntas frequentes
Preciso de um SDK ou framework de agentes para fazer isso no Laravel?
Não. O loop de tool calling cabe em uma classe. SDKs ajudam com tipagem, mas o Http:: dá controle total sobre retry, timeout e logs.
Como impedir que o agente execute ações para outro cliente?
Nunca aceite identificadores de usuário no input das ferramentas. O cliente vem do contexto autenticado (o telefone do webhook) e é injetado pelo código em handle().
Dá para usar o mesmo padrão com GPT ou Gemini?
Dá. O conceito é o mesmo; mudam o formato do payload e o nome dos campos. Isolar o cliente HTTP em uma classe facilita a troca.
Como evitar que o modelo confirme um agendamento que falhou?
Instrua no system prompt a só confirmar após o resultado da ferramenta e devolva falhas com is_error: true. Para casos críticos, gere a mensagem de confirmação a partir do registro no banco, não do texto do modelo.
Quantas ferramentas posso dar para o agente?
Comece com poucas e bem descritas. Cada ferramenta aumenta tokens de entrada e chances de escolha errada; separe agentes por domínio antes de passar de uma dezena.
Conclusão prática
Se for implementar amanhã, comece pela ferramenta de leitura (listar_horarios), coloque o agente em produção só respondendo dúvidas e observe os logs de tool_use por alguns dias. Só depois libere a ferramenta que escreve, com cliente vindo do contexto, validação no handle(), consistência garantida pelo banco e $tries = 1 no job. O modelo decide o que fazer; o seu código decide o que é permitido.