Montando um agente mínimo viável com Claude API + Laravel
Todo mundo que mexe com IA hoje acha que precisa de um framework de agente pra começar. LangChain, LangGraph, algum SDK com cinquenta abstrações. Você instala, briga com a documentação, e quando o agente quebra em produção você não faz ideia do que aconteceu lá dentro.
Um agente de IA é mais simples do que isso. No fundo é um loop: você manda um prompt, o modelo decide chamar uma ferramenta, você executa essa ferramenta no seu código, devolve o resultado, e o modelo continua até ter a resposta final. E dá pra escrever esse loop com Claude API e Laravel em PHP puro, sem mais nada. O resto é açúcar.
Neste tutorial eu vou montar um agente funcional em PHP puro com Laravel, sem framework de agente nenhum — só o cliente HTTP nativo do framework batendo direto na Claude API. É um recorte que quase ninguém cobre em PT-BR, e serve de prova: você não precisa de LangChain pra ter um loop que funciona.
TL;DR
- O que é: um agente mínimo viável em Laravel que conversa com a Claude API, chama ferramentas do seu próprio código e responde — escrito do zero, sem lib de agente.
- Stack/Modelos: PHP 8.3, Laravel 11,
Httpfacade nativo, Claudeclaude-opus-4-8(ouclaude-sonnet-4-6pra economizar) via Messages API. - Custo/Acesso: requer chave paga da Claude API. Sem dependência extra além do Laravel.
- O que você vai construir: uma classe
ClaudeAgentcom o loopprompt → tool_use → tool_result → resposta, tratamento de erro comis_errore limite de iterações.
O contexto — o que é um "agente", sem hype
Tira a palavra "agente" do pedestal por um segundo.
Uma chamada de chat normal é um tiro só: você manda mensagem, o modelo responde texto, acabou. O que transforma isso em agente é dar ao modelo a capacidade de agir antes de responder — consultar um banco, chamar uma API, ler um arquivo. E o mecanismo pra isso na Claude API se chama tool use.
Funciona assim. Você descreve as ferramentas disponíveis no parâmetro tools. O modelo, em vez de responder texto, pode devolver stop_reason: "tool_use" com um bloco dizendo qual ferramenta quer chamar e com quais argumentos. Aí entra a parte importante: quem executa a ferramenta é o seu código, não a Anthropic. Você roda, pega o resultado e devolve numa nova mensagem. O modelo lê o resultado e segue. (docs oficiais).
Esse vai-e-volta é o agente. Um while que só para quando o modelo decide que não precisa mais de ferramenta nenhuma e responde direto.
Existe ferramenta boa pra abstrair isso em Laravel — o Prism é excelente e eu uso em projeto sério. Mas se você nunca escreveu o loop na mão, você não entende o que o Prism está fazendo por baixo. E quando quebra, você fica refém. Então hoje a gente escreve na mão.
Pré-requisitos
Antes de codar, você precisa de:
- [ ] Chave de API da Anthropic (
ANTHROPIC_API_KEYno.env) - [ ] Laravel 11 rodando, PHP 8.3+
- [ ] Noção de como o
Httpfacade do Laravel funciona (Http::post()) - [ ] Nenhum pacote de IA instalado — é esse o ponto
Registre a chave em config/services.php:
'anthropic' => [
'key' => env('ANTHROPIC_API_KEY'),
],
Mão na massa — o agente com Claude API e Laravel em uma classe
Passo 1: a chamada crua à Messages API
Antes do loop, o tijolo básico: uma requisição à API. O endpoint é https://api.anthropic.com/v1/messages, com dois headers obrigatórios — x-api-key e anthropic-version.
<?php
namespace App\Agent;
use Illuminate\Support\Facades\Http;
class ClaudeAgent
{
private string $model = 'claude-opus-4-8';
private array $messages = [];
public function __construct(
private array $tools, // definições das ferramentas (formato da API)
private array $handlers, // ['nome_da_tool' => callable que executa]
private string $system = '',
) {}
private function call(): array
{
return Http::withHeaders([
'x-api-key' => config('services.anthropic.key'),
'anthropic-version' => '2023-06-01',
])->post('https://api.anthropic.com/v1/messages', [
'model' => $this->model,
'max_tokens' => 1024,
'system' => $this->system,
'tools' => $this->tools,
'messages' => $this->messages,
])->throw()->json();
}
}
Repare que não tem mágica. É um POST. O ->throw() faz o Laravel estourar exception em status 4xx/5xx — você quer saber quando a API recusa o payload, não engolir em silêncio.
Passo 2: descrever as ferramentas
Uma ferramenta é só um nome, uma descrição e um schema dos argumentos (JSON Schema). A descrição é o que o modelo lê pra decidir quando chamar — escreva pra ela como você escreveria pra um colega que nunca viu seu sistema.
$tools = [
[
'name' => 'consultar_pedido',
'description' => 'Busca o status atual de um pedido pelo número. '
. 'Use sempre que o cliente perguntar onde está o pedido dele.',
'input_schema' => [
'type' => 'object',
'properties' => [
'numero' => [
'type' => 'string',
'description' => 'Número do pedido, ex: "PED-1042".',
],
],
'required' => ['numero'],
],
],
];
E o handler — o código que de fato roda quando o modelo pede essa ferramenta. Aqui é Eloquent normal, é a sua aplicação de sempre:
$handlers = [
'consultar_pedido' => fn (array $input) =>
Pedido::where('numero', $input['numero'])->value('status')
?? "Pedido {$input['numero']} não encontrado.",
];
O modelo nunca toca no seu banco. Ele só pede "rode consultar_pedido com numero=PED-1042" e você decide o que isso significa. Esse é o ponto onde arquitetura e segurança moram — não dê ao agente uma ferramenta que executa SQL cru ou shell sem validar a entrada.
Passo 3: o loop
Agora o coração. O método ask() empilha a pergunta do usuário e roda o vai-e-volta até o modelo parar de pedir ferramenta:
public function ask(string $prompt, int $maxSteps = 10): string
{
$this->messages[] = ['role' => 'user', 'content' => $prompt];
for ($step = 0; $step < $maxSteps; $step++) {
$response = $this->call();
// a resposta do modelo (incluindo o tool_use) entra no histórico
$this->messages[] = [
'role' => 'assistant',
'content' => $response['content'],
];
// o modelo respondeu direto? acabou.
if ($response['stop_reason'] !== 'tool_use') {
return $this->extractText($response['content']);
}
// ele pediu ferramenta: executa e devolve o resultado
$this->messages[] = [
'role' => 'user',
'content' => $this->runTools($response['content']),
];
}
throw new \RuntimeException("Agente não convergiu em {$maxSteps} passos.");
}
Três coisas merecem atenção aqui.
Primeira: a mensagem assistant com o tool_use tem que entrar no histórico antes do resultado. A API recusa um tool_result que não vem logo depois do tool_use correspondente.
Segunda: o for com maxSteps. Um while (true) aqui é pedir pra um bug ou uma alucinação te dar um loop infinito que queima token até o teto do cartão. Limite sempre.
Terceira: stop_reason é o que dirige tudo. Enquanto for "tool_use", continua. Qualquer outra coisa ("end_turn", "max_tokens"), o modelo terminou.
Passo 4: executar as ferramentas e devolver o resultado
Falta o runTools(). Ele varre os blocos tool_use da resposta, roda o handler de cada um e monta os blocos tool_result:
private function runTools(array $content): array
{
$results = [];
foreach ($content as $block) {
if (($block['type'] ?? null) !== 'tool_use') {
continue; // blocos de texto a gente ignora aqui
}
$handler = $this->handlers[$block['name']] ?? null;
try {
if (! $handler) {
throw new \RuntimeException("Tool '{$block['name']}' não registrada.");
}
$results[] = [
'type' => 'tool_result',
'tool_use_id' => $block['id'],
'content' => (string) $handler($block['input']),
];
} catch (\Throwable $e) {
// erro vira contexto pro modelo, não exception engolida
$results[] = [
'type' => 'tool_result',
'tool_use_id' => $block['id'],
'content' => $e->getMessage(),
'is_error' => true,
];
}
}
return $results;
}
private function extractText(array $content): string
{
return collect($content)
->where('type', 'text')
->pluck('text')
->implode("\n");
}
O tool_use_id é o que casa o resultado com o pedido — é por ele que o modelo sabe qual resposta é de qual ferramenta. E o is_error: true é ouro: em vez de derrubar a request quando o handler falha, você devolve o erro como texto pro modelo, e ele decide se tenta de novo, se pede outro dado ou se explica pro usuário. (docs).
Juntando tudo
$agent = new ClaudeAgent(
tools: $tools,
handlers: $handlers,
system: 'Você é o atendente da loja. Responda curto, em português. '
. 'Use as ferramentas antes de afirmar qualquer coisa sobre pedidos.',
);
echo $agent->ask('Onde está meu pedido PED-1042?');
// → "Seu pedido PED-1042 está com status: em transporte."
Por baixo, o que aconteceu: o modelo recebeu a pergunta, decidiu chamar consultar_pedido, seu Eloquent rodou, o status voltou pro modelo, e ele formatou a resposta em português. Um agente. Em ~80 linhas de PHP. Sem framework de agente.
Limitações e pontos de atenção
Esse é o esqueleto mínimo. O que ele não é, e onde você vai se queimar se achar que é:
- Sem persistência. O
$messagesvive na memória da request. Pra um chat de verdade você precisa serializar o histórico (banco, cache) e recarregar a cada turno. - Sem streaming. A resposta vem de uma vez. Pra UX de "digitando..." você precisa do endpoint com
stream: truee Server-Sent Events. - Custo cresce com o histórico. Cada volta do loop reenvia toda a conversa. Em agentes com muitas iterações isso explode — é onde entra prompt caching, que o exemplo acima ainda não usa.
- Segurança não é opcional. Se uma ferramenta toca dados sensíveis ou executa ação destrutiva, valide a entrada e nunca confie cegamente nos argumentos que o modelo gerou. Tool use é poder; poder sem guardrail é incidente.
Esse bloco é o que separa demo de produção. A demo funciona na primeira vez na frente da câmera. Produção é quando o histórico cresceu, a API deu 429 e o handler levantou exception.
FAQ rápido
Recebi erro 400 "tool_use ids were found without tool_result blocks". O que é?
Você mandou o tool_result sem ter colocado antes a mensagem assistant com o tool_use, ou pôs texto antes do tool_result no array de content. O tool_result tem que vir primeiro no content da mensagem user, logo depois do turno do assistant. Reveja a ordem no Passo 3.
Recebi 429. E agora?
Rate limit. Trate como qualquer integração HTTP: backoff exponencial e retry. O Http do Laravel já tem ->retry(3, 100) nativo — encaixe no método call().
Preciso do Prism ou de algum framework? Pra aprender, não. Pra produção séria, provavelmente sim — o Prism resolve caching, streaming, múltiplos providers e parsing de structured output sem você reescrever tudo. A graça de fazer na mão primeiro é entender o que ele abstrai.
Qual modelo usar?
claude-opus-4-8 pra raciocínio mais pesado, claude-sonnet-4-6 pro custo-benefício no dia a dia, claude-haiku-4-5 pra tarefas simples e baratas. Troque a string em $model; o loop é idêntico.
Conclusão
Você construiu um agente. Não um chatbot que cospe texto — um loop que decide, age no seu código e responde com base no resultado. Tudo isso em PHP puro com Laravel, batendo direto na Claude API, sem uma única dependência de framework de agente.
A tese aqui é simples: o próximo salto do dev não é instalar a lib da moda, é entender o mecanismo embaixo dela. Quem entende o loop tool_use → tool_result lê qualquer framework de agente como código familiar, não como caixa-preta. O passo natural depois deste é dar autonomia a esse loop — transformar o atendente reativo num harness que recebe uma tarefa e roda sozinho até concluir, com logging e múltiplas ferramentas. E se a dúvida é qual ferramenta dar ao agente, o trade-off entre scraping, API e MCP é o próximo capítulo dessa conversa.
Essa, no fim, é a fronteira que separa quem usa ChatGPT no trabalho de quem constrói produto com IA: saber arquitetar o sistema em volta do modelo. É exatamente esse tipo de decisão de arquitetura — onde mora a tool, quem valida a entrada, como o loop se comporta sob falha — que a gente coloca na mesa, com código rodando, no Workshop Arquitetando Soluções de IA. Se montar o loop na mão te deu vontade de ir mais fundo na arquitetura, é pra lá que a conversa continua.
{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.
Você também pode gostar
Construindo seu primeiro harness em Laravel: do prompt isolado ao loop autônomo
Construa do zero um harness em Laravel mais Claude API: um service PHP que recebe a tarefa, escolhe qual tool chamar, executa em loop ate concluir e reporta. Inclui handling de erros com is_error, limite de iteracoes e logging real. Codigo executavel, sem framework de agente.
Hands-on: meu primeiro Pull Request 100% gerado por agente em Laravel (com diff e revisão)
Liguei o agente, fui tomar café e voltei 43 minutos depois com um PR de 380 linhas em 9 arquivos. Case study real com harness Laravel + Claude Agent SDK + sandbox isolado, a task escolhida, o loop cronometrado de 43 min em 12 iterações, o diff comentado, os 3 bugs que escaparam pro code review humano, custo total em USD e o veredito sobre soltar isso em produção. Repositório público no final.
Hands-on: construindo um agente de ofertas em 80 linhas com Claude, tool use e um reranker
Tutorial reproduzivel em Python: agent loop com Claude, busca na web, rerank do Cohere e saida em JSON estruturado. Esqueleto de 80 linhas para voce expandir e levar para producao.
Multi-agent em Laravel: 3 padrões testados em produção (Orchestrator, Hierarchical, Swarm)
Três arquiteturas multi-agent que sobreviveram a um projeto Laravel em produção: Orchestrator-Worker, Hierarchical e Swarm. Tem código real (Prism PHP, PrismAgents, Bus::batch, State Machine), o anti-padrão dos "4 prompts em paralelo" e o custo medido (hierarchical 30% mais barato).