~ / tutoriais /agente-minimo-viavel-claude-api-laravel $ _

Montando um agente mínimo viável com Claude API + Laravel

Lucas Souza Lucas Souza 3 min de leitura Tutoriais
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, Http facade nativo, Claude claude-opus-4-8 (ou claude-sonnet-4-6 pra 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 ClaudeAgent com o loop prompt → tool_use → tool_result → resposta, tratamento de erro com is_error e 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_KEY no .env)
  • [ ] Laravel 11 rodando, PHP 8.3+
  • [ ] Noção de como o Http facade 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 $messages vive 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: true e 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.

Lucas Souza
Lucas Souza

{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
Tutoriais

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.

· 4 min
Hands-on: meu primeiro Pull Request 100% gerado por agente em Laravel (com diff e revisão)
Tutoriais

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.

· 13 min
Hands-on: construindo um agente de ofertas em 80 linhas com Claude, tool use e um reranker
Tutoriais

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.

· 7 min
Multi-agent em Laravel: 3 padrões testados em produção (Orchestrator, Hierarchical, Swarm)
Tutoriais

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).

· 12 min

VirguIA

beer & code assistant

conectando…

Não foi possível iniciar o chat agora.

tocando