Skip to content
The exact pattern for registering tools, dispatching multi-turn Claude conversations, and processing tool results in a L...

A Claude Tool-Calling Loop in Laravel: From First Request to Final Answer

Al Amin Ahamed

Al Amin Ahamed

Senior Software Engineer

0 min read

A Claude Tool-Calling Loop in Laravel: From First Request to Final Answer

The tool use API is where Claude stops being a text generator and starts being an agent. You define a set of tools — functions with typed inputs — and Claude decides which to call, in what order, to answer a prompt. Your Laravel app executes the calls and returns results. Claude reasons over those results and either calls more tools or produces a final answer.

The loop is conceptually simple. The implementation has three places where it breaks in production if you do not handle them explicitly.

Defining tools

Each tool is an array matching Claude's JSON Schema format. The description is load-bearing — Claude uses it to decide whether this is the right tool for the job, so vague descriptions produce wrong selections.

PHP
[
    'name'         => 'get_order_status',
    'description'  => 'Retrieve the current fulfilment status and tracking number for a given order ID. Use this when the user asks where their order is or whether it has shipped.',
    'input_schema' => [
        'type'       => 'object',
        'properties' => [
            'order_id' => [
                'type'        => 'integer',
                'description' => 'The numeric order ID.',
            ],
        ],
        'required' => ['order_id'],
    ],
]

The registry

The registry holds two things: the JSON Schema definitions Claude sees, and the PHP callables your app executes. They are registered together, retrieved separately.

PHP
class ToolRegistry
{
    /** @var array<string, array{definition: array<string, mixed>, handler: callable}> */
    private array $tools = [];

    /**
     * Register a tool with its schema and execution handler.
     *
     * @param string               $name       Tool name matching the definition.
     * @param array<string, mixed> $definition JSON Schema definition sent to Claude.
     * @param callable             $handler    Receives tool input array, returns string or array result.
     */
    public function register(string $name, array $definition, callable $handler): void
    {
        $this->tools[$name] = ['definition' => $definition, 'handler' => $handler];
    }

    /** @return list<array<string, mixed>> */
    public function definitions(): array
    {
        return array_values(array_map(fn($t) => $t['definition'], $this->tools));
    }

    public function dispatch(string $name, array $input): string
    {
        if (! isset($this->tools[$name])) {
            return json_encode(['error' => "Unknown tool: {$name}"], JSON_THROW_ON_ERROR);
        }

        try {
            $result = ($this->tools[$name]['handler'])($input);
            return is_string($result) ? $result : json_encode($result, JSON_THROW_ON_ERROR);
        } catch (\Throwable $e) {
            return json_encode(['error' => $e->getMessage()], JSON_THROW_ON_ERROR);
        }
    }
}

The \Throwable catch in dispatch is deliberate. A tool that throws should return an error string to Claude, not unwind the agent loop. Claude then decides whether to retry with different input, try a different tool, or tell the user it failed.

The loop

PHP
class AgentService
{
    private const MAX_ITERATIONS = 10;

    public function __construct(
        private readonly \Anthropic\Client $client,
        private readonly ToolRegistry $registry,
    ) {}

    /**
     * Run the agent loop until Claude produces a final text response.
     *
     * @throws AgentException When the iteration ceiling is exceeded.
     */
    public function run(string $prompt): string
    {
        $messages   = [['role' => 'user', 'content' => $prompt]];
        $iterations = 0;
        $response   = null;

        do {
            if (++$iterations > self::MAX_ITERATIONS) {
                throw new AgentException('Agent exceeded maximum iterations.');
            }

            $response = $this->client->messages()->create([
                'model'      => 'claude-opus-4-7',
                'max_tokens' => 4096,
                'tools'      => $this->registry->definitions(),
                'messages'   => $messages,
            ]);

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

            if ($response->stop_reason !== 'tool_use') {
                break;
            }

            $tool_results = [];

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

                $tool_results[] = [
                    'type'        => 'tool_result',
                    'tool_use_id' => $block->id,
                    'content'     => $this->registry->dispatch($block->name, (array) $block->input),
                ];
            }

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

        } while (true);

        return collect($response->content)
            ->filter(fn($block) => $block->type === 'text')
            ->map(fn($block) => $block->text)
            ->implode("\n");
    }
}

Three things in the loop worth naming explicitly.

MAX_ITERATIONS is the only guardrail against a confused model that keeps calling tools indefinitely. Without it, a bad prompt or an unexpected tool result can produce an infinite loop and drain your API budget. Ten is a conservative default — raise it for complex multi-step workflows, not as a first response to an agent that gets stuck.

The assistant turn must append the full $response->content array, not just the text blocks. Claude's tool use blocks live in that content array. Strip them and the next request loses context; the model then produces incoherent output or repeats tool calls it already made.

tool_use_id links each result back to the specific call that generated it. Claude can issue multiple tool calls in a single turn — always match results to IDs, never assume positional order.

Wiring it up in a service provider

PHP
$this->app->singleton(ToolRegistry::class, function (): ToolRegistry {
    $registry = new ToolRegistry();

    $registry->register(
        'get_order_status',
        [
            'name'         => 'get_order_status',
            'description'  => 'Get the fulfilment status and tracking number for an order.',
            'input_schema' => [
                'type'       => 'object',
                'properties' => ['order_id' => ['type' => 'integer']],
                'required'   => ['order_id'],
            ],
        ],
        function (array $input): array {
            $order = Order::findOrFail((int) $input['order_id']);

            return [
                'status'          => $order->status,
                'tracking_number' => $order->tracking_number,
                'shipped_at'      => $order->shipped_at?->toISOString(),
            ];
        }
    );

    return $registry;
});

$this->app->singleton(AgentService::class, fn($app) => new AgentService(
    \Anthropic\Anthropic::factory()
        ->withApiKey(config('services.anthropic.key'))
        ->make(),
    $app->make(ToolRegistry::class),
));

Failure modes at the call site

The loop handles the happy path. Two failure modes belong at the call site, not inside the service.

AgentException means the iteration ceiling was hit. Log it, surface a degraded response to the user, and investigate which tool result is causing the loop. In practice this almost always means a tool returned an error string that Claude interpreted as a reason to keep trying — fix the tool's error message to be unambiguous about finality.

Rate limit errors from the Anthropic client are transient. Wrap run() in Laravel's retry() helper: retry(3, fn() => $agent->run($prompt), 1000). Do not catch rate limit errors inside AgentService — the retry decision belongs to the caller, not the loop.

This pattern underpins EasyCommerce's AI product management layer and the agent panel in this portfolio. The registry has grown to 46 tools without a single change to the loop — register a definition and a handler, and deciding what to call is Claude's problem.

Share 𝕏 in
Al Amin Ahamed

Al Amin Ahamed

Senior software engineer & AI practitioner. 5+ years shipping Laravel platforms, WordPress plugins, WooCommerce extensions, and AI-driven products.

About me →

More from the blog

Need this kind of work shipped?

Available for freelance and consulting.

Laravel platforms, WordPress plugins, WooCommerce extensions, and AI integrations.