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 ClaudeClient que chama a Messages API da Anthropic via Http::, com timeout e retry;
  • duas ferramentas (listar_horarios e agendar_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:

  1. PHP 8.2+ e Laravel 11 ou 12.
  2. Uma chave da API da Anthropic (console da Anthropic).
  3. Fila configurada (Redis + Horizon é o que eu uso, mas database funciona).
  4. Um model Cliente identificado pelo telefone e uma regra de agenda já existente. Aqui ela aparece como AgendaService; 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:

  1. Reenvio o content inteiro do assistant, não só o texto. A API exige que cada tool_result aponte para um tool_use presente na mensagem anterior.
  2. Erros de validação e de negócio voltam como tool_result com is_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.
  3. 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

  1. 400 com "Input should be a valid dictionary": uma ferramenta chamada sem argumentos volta com input: {}, o json() converte para array vazio e o json_encode reenvia []. Por isso o normalizar() faz cast para object. O mesmo vale para properties vazio no input_schema.
  2. 400 dizendo que tool_use não tem tool_result correspondente: 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 mensagem user seguinte.
  3. Agente em loop: a ferramenta falha sempre do mesmo jeito e o modelo tenta de novo. O MAX_ITERACOES corta isso; mensagens de erro claras ("formato esperado YYYY-MM-DD") resolvem a maioria dos casos.
  4. Job morto no meio da conversa: $timeout do job menor que a soma das chamadas HTTP. Com timeout de 60s e até 5 iterações, dimensione o job e o retry_after da conexão de fila acima disso.
  5. 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.
  6. 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.