See every step your laravel/ai agent takes: tokens, tool calls and cost with one composer require

A laravel/ai agent with a tool, instrumented in one composer require. What each step cost, what the tool was asked and what it returned, and what a failure looks like when the run itself reports success.

The Laravel AI SDK makes it easy to build an agent that calls tools in a loop until it has an answer. It makes it much less easy to see what happened inside that loop once it is running for real: how many model calls a request took, what the tool was asked, what it returned, and what the whole thing cost.

This post builds a small support agent with one tool, adds AgentPing to the app, and walks through what shows up. There are no code changes to the agent. The package listens to the events laravel/ai already fires and turns each invocation into a run on the dashboard.

The agent

A support agent that can look up an order. Two classes, both generated with the SDK's Artisan commands.

php artisan make:agent SupportAgent
php artisan make:tool LookupOrder

The tool takes an order number and returns the order as JSON. In a real app it would query a model; here it reads from an array so you can paste the whole thing and run it.

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class LookupOrder implements Tool
{
    private const ORDERS = [
        'A-1042' => ['status' => 'shipped', 'carrier' => 'DPD', 'eta' => '2026-10-03'],
        'A-1043' => ['status' => 'processing', 'carrier' => null, 'eta' => null],
    ];

    public function description(): Stringable|string
    {
        return 'Look up an order by its order number and return its status, carrier and estimated delivery date.';
    }

    public function handle(Request $request): Stringable|string
    {
        $order = self::ORDERS[$request['order_number']] ?? null;

        if ($order === null) {
            throw new \RuntimeException("Order {$request['order_number']} not found");
        }

        return json_encode($order);
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_number' => $schema->string()->required(),
        ];
    }
}

The agent gives the model its instructions and the tool.

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\LookupOrder;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;

class SupportAgent implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are a support agent for an online shop. Use the LookupOrder tool to answer questions about orders. Be brief.';
    }

    public function tools(): iterable
    {
        return [new LookupOrder];
    }
}

And a route to prompt it, so the whole thing can be exercised from a browser or curl.

use App\Ai\Agents\SupportAgent;

Route::get('/support', function () {
    $response = (new SupportAgent)->prompt(
        'Where is order '.request('order', 'A-1042').'?'
    );

    return (string) $response;
});

Hit /support and you get a sentence back: the order shipped with DPD and should arrive on the 3rd. Under the hood the SDK made a model call, the model asked for LookupOrder, the tool ran, and the SDK made a second model call with the result. None of that is visible in the response, and none of it is in your logs unless you put it there.

Add AgentPing

composer require agentping/laravel
AGENTPING_API_KEY=apk_eu_...

That is the whole installation. The service provider is auto-discovered, the ingest region is read from the key, and the package registers listeners for the SDK's lifecycle events. Nothing in SupportAgent or LookupOrder changes.

Hit /support again and open the dashboard. There is a new agent called support_agent, named from the class, with one run.

Reading top to bottom:

  1. llm_call, step 1. The provider and model, input and output tokens, latency, and a cost computed on the server from the current rate card. The finish reason is a tool call, and the step says how many tools the model asked for.
  2. tool_call, LookupOrder. The arguments the model sent ({"order_number":"A-1042"}), what the tool returned, and how long it took.
  3. llm_call, step 2, final. The model with the tool result in context, producing the sentence you saw in the browser. Its own tokens and cost.

The run's total is the sum of the two model calls. The tool call has no cost; it is your code.

If you use prompt caching, the cached and cache-creation token counts appear on each step, and reasoning tokens show separately for models that report them. All of it comes from the SDK's own Usage object, so the numbers match what your provider bills.

Breaking it

Production code does not let an agent's exception reach the user. It catches it and returns something polite, which is the right thing to do and also the reason agent failures go unnoticed. Make the route do what production code does:

Route::get('/support', function () {
    try {
        $response = (new SupportAgent)->prompt(
            'Where is order '.request('order', 'A-1042').'?'
        );

        return (string) $response;
    } catch (\Throwable $e) {
        report($e);

        return 'Sorry, we could not check that order right now. Please try again shortly.';
    }
});

Now ask about an order that does not exist:

/support?order=A-9999

LookupOrder throws. The SDK does not hand a thrown tool back to the model; it fails the invocation, so the prompt throws, the route catches it, and the browser gets a 200 with an apology in it. The run tells a different story.

The tool_call is marked as an error with the exception message and class, the run ends with an error event carrying the same exception, and the run's status is error. That is the distinction that matters: the request succeeded, the agent did not. A "runs start failing" alert rule fires on the second kind, which is the kind nobody notices from the outside, and the timeline shows exactly which tool call took it down.

The same applies on the model side. If a step fails partway through, the run gets an errored llm_call for that step with the exception. Validation failures in tool arguments are different: the SDK returns those to the model so it can correct itself and retry, so they show up as a normal tool_call followed by another model step, not as an error.

Failover, and what a run looks like when it works

If you prompt with a list of providers, the SDK fails over when the first one is rate limited or overloaded:

$response = (new SupportAgent)->prompt(
    'Where is order A-1042?',
    provider: [Lab::Anthropic, Lab::OpenAI],
);

When that happens the run gets a provider_failover step naming the provider and model that were given up on and the reason, and the llm_call that follows names the provider that actually answered. The run's status stays what it was, because the prompt succeeded. Failover is information, not a failure, and it is recorded that way.

What gets sent

Tool arguments and results are sent, because they are what makes a tool call readable on the timeline. Prompt and completion text are never sent; the package reads the SDK's usage and metadata objects, not the messages. If a tool handles data you would rather not ship, turn payload capture off, or cap it:

AGENTPING_CAPTURE_TOOL_PAYLOADS=false
AGENTPING_TOOL_PAYLOAD_MAX_CHARS=4000

Telemetry is queued in memory and flushed after the response is sent, with a two-second timeout and a bounded queue. If AgentPing is unreachable the package logs a warning once and your agent carries on.

Grouping runs and naming things

For a single prompt in a route, the package creates the run for you and finishes it when the invocation ends. When one piece of work involves several invocations, or you want a customer on it, wrap it:

use AgentPing\Laravel\Facades\AgentPing;

$run = AgentPing::run('support-reply', customerId: $ticket->customer_id);

$triage = (new TriageAgent)->prompt($ticket->body);
$reply = (new SupportAgent)->prompt($ticket->body.' Category: '.$triage);

$run->finish('success');

Both invocations, their tool calls and their costs land on one run with a customer id, which is what makes per-customer cost a query rather than a spreadsheet. An agent that calls another agent through a tool is grouped automatically; the sub-agent's steps sit inside the outer run.

Already on OpenTelemetry?

If your app exports traces, the same agents can reach AgentPing over OTLP through the laravel-ai-otel package instead of this one. The mapping is the same, with a couple of differences in how failover reads and how quickly runs appear. The docs page covers both routes side by side.

Where this leaves you

Two classes and a route, one package, one env var, and every invocation of the agent is a timeline with tokens and cost on each model call, arguments and results on each tool call, and a status that reflects what the agent did rather than what the HTTP layer returned. The Laravel SDK docs have the full list of events and configuration, and the free plan covers two agents, which is enough to point it at the one you are least sure about.

Does AgentPing require changes to my laravel/ai agents?
No. The package listens to the events the Laravel AI SDK already dispatches (StepCompleted, ToolInvoked, AgentFailed and the rest) and reports them. Agents and tools are untouched. You only write code if you want to group several invocations into one run or attach a customer id.
Does AgentPing see my prompts?
No. Prompt and completion text are never sent. The package reads the SDK's usage and metadata objects for tokens, provider, model and latency. Tool arguments and results are sent by default so tool calls are readable on the timeline; `AGENTPING_CAPTURE_TOOL_PAYLOADS=false` turns that off.
How is cost calculated?
On the server, from the token counts the SDK reports and a rate card of provider prices. The package never sends a cost figure, so a price change is applied everywhere at once and cached and reasoning tokens are priced at their own rates.
What happens if AgentPing is down?
Nothing visible. Telemetry is queued in memory and flushed after the response with a two-second timeout. On failure the package logs one warning per error class and drops the oldest events if the queue fills. It never throws into your app.
Can I use this with OpenTelemetry instead?
Yes. The laravel-ai-otel package exports the same lifecycle as OTLP spans, and AgentPing accepts OTLP directly. The docs compare the two routes; the package on this page is the shorter one and the OTLP route suits apps that already export traces.