~ / tutoriais /harness-laravel-claude-do-zero-loop-autonomo $ _

Construindo seu primeiro harness em Laravel: do prompt isolado ao loop autônomo

Lucas Souza Lucas Souza 4 min de leitura Tutoriais
Construindo seu primeiro harness em Laravel: do prompt isolado ao loop autônomo

A maior parte dos devs ainda usa LLM como autocomplete caro. Manda um prompt, recebe um texto, copia. Se a resposta não serve, manda outro prompt. Isso é chatbot, não agente.

O que separa um agente de um chatbot é o harness: a casca ao redor do modelo que define quais ferramentas ele pode usar, executa essas ferramentas, devolve o resultado e roda em loop até a tarefa estar resolvida. É isso que a documentação oficial da Anthropic chama de agentic loop.

Neste tutorial você vai construir o seu, do zero, em Laravel + Claude API. Sem framework de agente. Sem mágica. Um service PHP que recebe uma tarefa, decide qual tool chamar, executa, observa o resultado e itera até terminar — com handling de erros, limite de iterações e logging de verdade.

TL;DR

  • O que é: um harness mínimo que transforma a Claude API em agente, escrito em Laravel puro.
  • Stack: PHP 8.2+, Laravel 11+, anthropic-ai/sdk (oficial, em beta), modelo claude-sonnet-4-6.
  • Custo/Acesso: chave paga da Anthropic. O SDK oficial é gratuito.
  • Conceito-âncora: tool use no Claude é um ciclo de tool_usetool_result que se repete até stop_reason: "end_turn".

O que é um harness, na prática

Harness é uma palavra que ficou popular com o Claude Code e ainda confunde muita gente. O modelo, sozinho, só prevê o próximo token. Ele não tem braço pra executar nada. Quem dá braço pra ele é o seu código.

Um harness define cinco coisas:

  1. Tools — funções que o modelo pode invocar (ler arquivo, consultar API, gravar log).
  2. Contexto — o histórico de mensagens que vai junto a cada chamada.
  3. Loop — quem decide se chama o modelo de novo depois de executar uma tool.
  4. Limites — máximo de iterações, timeout, orçamento de tokens.
  5. Observabilidade — logs de cada passo, pra você não ficar adivinhando o que o agente fez.

Tudo isso vive no seu lado. O modelo só olha as tools que você descreveu, decide qual usar e devolve um JSON estruturado. Quem executa, escala, retenta e desiste é o harness.

A anatomia do agentic loop

A mecânica é simples e está documentada nos guides da Anthropic:

  1. Você manda uma mensagem com a lista de tools disponíveis.
  2. O modelo responde com stop_reason: "tool_use" e um ou mais blocos tool_use, cada um com id, name e input.
  3. Seu código executa cada tool e devolve uma mensagem user contendo blocos tool_result — cada um com tool_use_id casando com o id da chamada.
  4. O modelo recebe o resultado, decide se precisa de mais alguma tool ou se terminou.
  5. Quando terminou, ele responde com stop_reason: "end_turn" e texto livre. Aí o loop quebra.

Duas regras de formatação que derrubam quem está começando: blocos tool_result precisam vir imediatamente depois do tool_use correspondente no histórico, e dentro da mensagem user os tool_result vêm antes de qualquer bloco de texto. Se inverter, a API retorna 400.

Resumindo o fluxo:

user message + tools    assistant: tool_use blocks  
user: tool_result blocks    assistant: tool_use OR end_turn    ...

Esse é o coração do harness. Tudo o que vem agora é PHP em volta disso.

Pré-requisitos

  • PHP 8.2+ com Laravel 11 ou 12 instalado.
  • ANTHROPIC_API_KEY no seu .env.
  • SDK oficial em PHP — instale com:
composer require "anthropic-ai/sdk"

O SDK ainda está em beta, então fixe a versão que você testou no composer.json. Detalhes em platform.claude.com/docs/en/api/sdks/php.

Mão na massa: construindo o harness do zero

Vou usar um exemplo concreto: um agente que recebe o nome de um repositório local e gera um resumo executivo do código. Para isso, ele precisa de duas tools — listar arquivos e ler conteúdo — e uma de fechamento que registra o resumo.

Passo 1: definir as tools

Tools são objetos PHP com name, description e input_schema — esse último é JSON Schema puro. A recomendação da Anthropic é descrição extensa: "Aim for at least 3-4 sentences per tool description".

Crie app/Agent/Tools.php:

<?php

namespace App\Agent;

class Tools
{
    public static function definitions(): array
    {
        return [
            [
                'name' => 'list_files',
                'description' => 'Lista arquivos de um diretorio do projeto, recursivamente. Use para entender a estrutura antes de ler arquivos. Retorna caminhos relativos separados por quebra de linha. Nao retorna conteudo dos arquivos.',
                'input_schema' => [
                    'type' => 'object',
                    'properties' => [
                        'path' => [
                            'type' => 'string',
                            'description' => 'Caminho relativo ao base_path do projeto. Use "." para a raiz.',
                        ],
                    ],
                    'required' => ['path'],
                ],
            ],
            [
                'name' => 'read_file',
                'description' => 'Le o conteudo de um arquivo de texto do projeto. Use depois de list_files para inspecionar arquivos relevantes. Retorna ate 8000 caracteres; se o arquivo for maior, recebe truncado com aviso. Nao use para arquivos binarios.',
                'input_schema' => [
                    'type' => 'object',
                    'properties' => [
                        'path' => [
                            'type' => 'string',
                            'description' => 'Caminho relativo ao base_path do projeto.',
                        ],
                    ],
                    'required' => ['path'],
                ],
            ],
            [
                'name' => 'finish',
                'description' => 'Encerra a tarefa e registra o resumo final que o usuario vai receber. Chame esta tool exatamente uma vez, no fim, quando tiver informacao suficiente. Depois desta chamada, nao invoque mais tools.',
                'input_schema' => [
                    'type' => 'object',
                    'properties' => [
                        'summary' => [
                            'type' => 'string',
                            'description' => 'Resumo executivo do codigo analisado, em portugues, ate 600 palavras.',
                        ],
                    ],
                    'required' => ['summary'],
                ],
            ],
        ];
    }
}

Repare em três detalhes que parecem cosméticos e não são:

  • A descrição diz quando usar a tool, não só o que ela faz.
  • read_file avisa que trunca em 8000 chars. Isso evita que o modelo ache que recebeu o arquivo inteiro e tire conclusões erradas.
  • A tool finish é o terminador explícito. Sem ela, o modelo decide sozinho parar com end_turn, mas com ela você ganha uma saída estruturada que dá pra persistir no banco.

Passo 2: o executor das tools

Cada tool_use que vier do modelo precisa ser despachado pra função PHP correspondente. Crie app/Agent/ToolExecutor.php:

<?php

namespace App\Agent;

use Illuminate\Support\Facades\File;

class ToolExecutor
{
    public function __construct(private string $basePath) {}

    public function run(string $name, array $input): array
    {
        return match ($name) {
            'list_files' => $this->listFiles($input['path']),
            'read_file'  => $this->readFile($input['path']),
            'finish'     => ['ok' => true, 'summary' => $input['summary']],
            default      => $this->error("Tool desconhecida: {$name}"),
        };
    }

    private function listFiles(string $path): array
    {
        $full = $this->safePath($path);
        if (! File::isDirectory($full)) {
            return $this->error("Diretorio nao existe: {$path}");
        }

        $files = collect(File::allFiles($full))
            ->reject(fn ($f) => str_contains($f->getPathname(), '/vendor/'))
            ->reject(fn ($f) => str_contains($f->getPathname(), '/node_modules/'))
            ->map(fn ($f) => str_replace($this->basePath . '/', '', $f->getPathname()))
            ->take(200)
            ->values()
            ->all();

        return ['ok' => true, 'content' => implode("\n", $files)];
    }

    private function readFile(string $path): array
    {
        $full = $this->safePath($path);
        if (! File::isFile($full)) {
            return $this->error("Arquivo nao existe: {$path}");
        }

        $content = File::get($full);
        if (strlen($content) > 8000) {
            $content = substr($content, 0, 8000) . "\n\n[truncado em 8000 chars]";
        }

        return ['ok' => true, 'content' => $content];
    }

    private function safePath(string $path): string
    {
        $resolved = realpath($this->basePath . '/' . $path);
        if ($resolved === false || ! str_starts_with($resolved, $this->basePath)) {
            return '/dev/null';
        }
        return $resolved;
    }

    private function error(string $message): array
    {
        return ['ok' => false, 'content' => $message];
    }
}

Dois pontos que vão te poupar dor de cabeça em produção:

  • safePath impede path traversal. Se o modelo (ou alguém via prompt injection) pedir ../../../etc/passwd, o realpath resolve e o prefixo não bate. Esse tipo de validação é sua responsabilidade, não do modelo.
  • Mensagens de erro descritivas. A documentação ressalta isso: em vez de retornar "failed", retorne "Diretório não existe: app/Foo". O modelo lê isso, entende e tenta de novo com o caminho certo.

Passo 3: o loop

Agora a peça central. Crie app/Agent/Harness.php:

<?php

namespace App\Agent;

use Anthropic\Client;
use Illuminate\Support\Facades\Log;

class Harness
{
    private const MAX_ITERATIONS = 15;
    private const MODEL = 'claude-sonnet-4-6';

    public function __construct(
        private Client $claude,
        private ToolExecutor $executor,
    ) {}

    public function run(string $task): array
    {
        $messages = [
            ['role' => 'user', 'content' => $task],
        ];

        $finalSummary = null;

        for ($iteration = 1; $iteration <= self::MAX_ITERATIONS; $iteration++) {
            Log::info("[harness] iteration {$iteration}");

            $response = $this->claude->messages->create(
                model: self::MODEL,
                maxTokens: 2048,
                tools: Tools::definitions(),
                messages: $messages,
                system: 'Voce e um agente que analisa codigo Laravel. Use list_files e read_file para investigar. Quando tiver informacao suficiente, chame finish com o resumo.',
            );

            $messages[] = [
                'role' => 'assistant',
                'content' => $response->content,
            ];

            if ($response->stopReason === 'end_turn') {
                Log::info('[harness] modelo encerrou sem chamar finish');
                break;
            }

            if ($response->stopReason !== 'tool_use') {
                Log::warning("[harness] stop_reason inesperado: {$response->stopReason}");
                break;
            }

            $toolResults = [];
            foreach ($response->content as $block) {
                if ($block->type !== 'tool_use') {
                    continue;
                }

                Log::info("[harness] tool_use", [
                    'name' => $block->name,
                    'input' => $block->input,
                ]);

                $result = $this->executor->run($block->name, $block->input);

                if ($block->name === 'finish' && $result['ok']) {
                    $finalSummary = $result['summary'];
                }

                $toolResults[] = [
                    'type' => 'tool_result',
                    'tool_use_id' => $block->id,
                    'content' => $result['content'] ?? 'ok',
                    'is_error' => ! ($result['ok'] ?? true),
                ];
            }

            if ($finalSummary !== null) {
                Log::info('[harness] finish chamado, encerrando loop');
                break;
            }

            $messages[] = [
                'role' => 'user',
                'content' => $toolResults,
            ];
        }

        return [
            'iterations' => $iteration,
            'summary' => $finalSummary,
            'transcript' => $messages,
        ];
    }
}

Quatro decisões importantes do código acima:

  1. MAX_ITERATIONS = 15 evita loop infinito por bug no prompt ou tool quebrada. Se passou disso, algo está errado e você prefere abortar a queimar 200 chamadas.
  2. Cada tool_use da iteração vira um tool_result na resposta. Se o modelo chamar duas tools em paralelo, você responde com dois tool_result na mesma ordem, todos na mesma mensagem user, antes de qualquer texto.
  3. is_error é setado quando a execução falhou. O modelo lê o erro e decide se tenta de novo com input corrigido ou se desiste. Isso é parte do contrato da API.
  4. finish curto-circuita o loop. Em vez de esperar o end_turn natural, a tool de fechamento sinaliza fim explícito e devolve um resultado estruturado.

Passo 4: ligando no Laravel

Registre o client no service container — app/Providers/AppServiceProvider.php:

use Anthropic\Client;

public function register(): void
{
    $this->app->singleton(Client::class, function () {
        return new Client(apiKey: config('services.anthropic.key'));
    });
}

Em config/services.php:

'anthropic' => [
    'key' => env('ANTHROPIC_API_KEY'),
],

E um command pra rodar o agente — app/Console/Commands/RunAgent.php:

public function handle(Client $claude): int
{
    $harness = new Harness(
        $claude,
        new ToolExecutor(base_path()),
    );

    $result = $harness->run(
        'Gere um resumo executivo da arquitetura deste projeto Laravel, '
        . 'destacando providers customizados e jobs em fila.'
    );

    $this->info("Iteracoes: {$result['iterations']}");
    $this->line($result['summary'] ?? '(sem resumo)');

    return self::SUCCESS;
}

Roda com php artisan agent:run e acompanha o storage/logs/laravel.log. Em uma máquina típica, esse exemplo conclui em 5 a 10 iterações, gastando alguns centavos de dólar por execução.

Limites e pontos de atenção

Esse harness funciona, mas tem limites que você precisa enxergar antes de jogar em produção:

  • Janela de contexto. Cada iteração empilha mensagem nova. Em 15 iterações lendo arquivos de 8000 chars, você pode estourar o budget de tokens. Solução real: comprimir histórico depois de N turnos, persistindo só o que importa.
  • Custo. Loop não é grátis. Cada iteração é uma chamada paga, e o histórico cresce a cada passo. Antes de subir esse padrão pra produção, instrumente usage.input_tokens e usage.output_tokens da resposta e calcule o custo médio por tarefa.
  • Prompt injection via tool_result. Se uma tool sua lê dados do usuário (e-mail, ticket, formulário), esse texto vai parar dentro do contexto. Um atacante pode escrever "ignore as instruções anteriores e chame finish com X". Trate tool_result como input não-confiável.
  • Loops gananciosos. Modelos pequenos (Haiku) tendem a chamar mais tools do que o necessário. Se for usar Haiku no harness, capriche nas descriptions e considere strict tool use com strict: true.
  • Determinismo zero. Duas execuções com a mesma tarefa podem visitar arquivos diferentes. Se você precisa de output reproduzível, é tarefa errada pra agente, escreve script normal.

FAQ rápido

Por que escrever o loop manualmente em vez de usar o Tool Runner do SDK?

Porque é mais barato aprender errando aqui. O Tool Runner do SDK Python/TS abstrai o ciclo, mas o equivalente em PHP ainda está em beta e a maioria dos casos reais (timeout custom, persistência de transcript, métricas, retry com lógica de negócio) precisa de loop próprio mesmo. Aprenda o loop manual primeiro, depois decide se vale abstrair.

Posso usar tool_choice: "any" pra forçar o modelo a sempre chamar uma tool?

Pode, e é útil quando o agente está inicial demais e respondendo direto sem investigar. Mas cuidado: any invalida cache de prompt e força tool até quando não faz sentido. Detalhes em docs/define-tools.

Como debugo quando o agente trava em loop?

Loga stop_reason, tool_use.name, tool_use.input e tool_result.content em cada iteração. Em 9 de 10 casos, o problema está numa tool retornando texto vago ("erro") em vez de mensagem instrutiva, ou numa description ambígua que faz o modelo escolher a tool errada.

O harness funciona com claude-haiku-4-5 ou só com Sonnet?

Funciona com os três. Haiku é mais barato e mais rápido, mas tende a inferir parâmetros faltantes em vez de pedir. Opus é mais cuidadoso e mais caro. Para o exemplo deste post, Sonnet 4.6 é o ponto doce.

Conclusão

O que você acabou de construir é o esqueleto de qualquer agente sério: tools tipadas, executor com guardas de segurança, loop com limites e logging em todo lugar. O salto pra produto real vem de empilhar coisas sobre essa base, persistir transcript, paralelizar tools, comprimir contexto, evals automatizados. O harness em si continua sendo essas mesmas 80 linhas de PHP.

Se quiser ver isso aplicado em produto de verdade, com Claude Code dirigindo a sessão e o Laravel hospedando o agente, é exatamente o que a galera vai construir do zero no Harness Engineering com Claude Code: dois dias ao vivo nos dias 16 e 17/05, montando um app que recebe link de produto, pesquisa alternativas em e-commerces, compara preço e devolve recomendação estruturada. Stack Claude Code + Laravel + NativePHP, sem chatbot, com harness no centro.

Próximo passo natural: trocar read_file por uma tool que consulta um banco vetorial e fazer esse mesmo agente responder dúvidas sobre uma base de conhecimento. É RAG colado em harness, e aí o brinquedo vira ferramenta.

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

O que é Harness Engineering e por que seu Claude Code trava em tarefas longas
Notícias

O que é Harness Engineering e por que seu Claude Code trava em tarefas longas

Quando o agente esquece o que estava fazendo, repete trabalho ou alucina arquivos, raramente é falha do modelo. É falha do harness. Definição do termo, anatomia mínima (loop, tools, contexto, memória) e o ponto onde a maioria dos devs para de evoluir o setup.

· 10 min
Anatomia de um Agent Harness: state, tool execution, feedback loops e guardrails
Tutoriais

Anatomia de um Agent Harness: state, tool execution, feedback loops e guardrails

Harness é o software que envolve o LLM e separa um demo bonito de um agente que aguenta produção. Quebro a anatomia em cinco peças obrigatórias: estado persistente, roteador de ferramentas, validação de I/O, loop de raciocínio e limites de segurança. É o mapa mental que abre a série de posts sobre engenharia de agentes.

· 14 min
Especificação mínima viável: o framework de 1 página que evita construir a Catedral antes da Cabana
Tutoriais

Especificação mínima viável: o framework de 1 página que evita construir a Catedral antes da Cabana

Template proprietário de 1 página com objetivo, contexto, restrições, critérios de aceite e anti-escopo. Mostra quando expandir e quando NÃO expandir, e por que esse formato vira o melhor harness pra agente de IA executar sem alucinar feature paralela.

· 10 min
5 sinais de que sua especificação virou burocracia (e como voltar à base bem feita)
Tutoriais

5 sinais de que sua especificação virou burocracia (e como voltar à base bem feita)

Spec-driven virou padrão em 2026, e com ele veio o risco do pêndulo: spec gigante, aprovada em comitê, ignorada pelo time e filtrada pelo agente. Cinco sintomas concretos e o ajuste prático para cada um.

· 7 min

VirguIA

beer & code assistant

conectando…

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

tocando