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:
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.tool_call,LookupOrder. The arguments the model sent ({"order_number":"A-1042"}), what the tool returned, and how long it took.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.