Adding Custom Tools
Introduction
The built-in tools cover general-purpose needs. Custom tools are how you give the agent access to code that knows about your specific application.
A tool is any class implementing Laravel\Ai\Contracts\Tool. Laraclaw also provides a BaseTool class that handles operation dispatch, schema definition, and confirmation flows.
Anatomy of a Tool
Every tool has three things:
- A description — natural-language text the agent reads to decide whether to use the tool
- A schema — a JSON schema describing the tool's input parameters
- A handler — the method that runs when the agent calls the tool
A tool that fetches the current weather for a city:
<?php
namespace App\Laraclaw\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Http;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class WeatherTool implements Tool
{
public function description(): Stringable|string
{
return 'Get the current weather for a city. Returns temperature, conditions, and humidity.';
}
public function schema(JsonSchema $schema): array
{
return [
'city' => $schema->string()->required()->description('The city name'),
'units' => $schema->string()->description('"metric" or "imperial" (default: metric)'),
];
}
public function handle(Request $request): Stringable|string
{
$city = $request['city'];
$units = $request['units'] ?? 'metric';
$response = Http::get('https://api.example.com/weather', [
'city' => $city,
'units' => $units,
]);
return $response->body();
}
}
The description is the most important field. The agent decides whether to call the tool based on it, so make it clear and specific.
Registering a Tool
Name the class in config/laraclaw.php:
use App\Laraclaw\Tools\WeatherTool;
'tools' => [
'custom' => [
WeatherTool::class,
],
],
That's the whole wiring. No service provider, no closure, no command to run.
A class that doesn't exist or doesn't implement Laravel\Ai\Contracts\Tool is skipped with a warning in your log rather than throwing, so a typo costs you one tool instead of taking down every reply.
Accessing the Current Message
Tools that depend on context (current connector, inbound attachments, thread's persona) accept IncomingMessage and Thread in their constructor:
public function __construct(
private readonly IncomingMessage $message,
private readonly Thread $thread,
) {}
Tools are built once per message, and the container fills in constructor arguments named $message and $thread for you. Everything else resolves from the container as usual, so you can inject your own services alongside them.
Registering at Runtime
Config is a static list. When the tools themselves need to vary per message — a full tool in a DM, a read-only one in a group — register a factory instead. It runs on every turn and receives the message and thread:
use Laraclaw\Tools\ToolRegistry;
public function boot(): void
{
$this->app->make(ToolRegistry::class)->register(
fn (IncomingMessage $message, ?Thread $thread) => $thread?->is_direct_message
? new WeatherTool($message)
: new PublicForecastTool($message),
);
}
Reach for this only when the tool list genuinely varies. Config is easier to find and survives config:cache, which closures do not.
Returning Files in the Reply
If your tool produces a file for the user, write it to the outbound attachments folder for the current message UUID:
use Laraclaw\Services\Attachments;
public function __construct(
private readonly IncomingMessage $message,
private readonly Attachments $attachments,
) {}
public function handle(Request $request): string
{
$report = $this->generateReport();
$this->attachments
->outbound($this->message->uuid)
->set('report.pdf', $report);
return 'Report generated. It will be attached to your reply.';
}
The connector picks the file up automatically when it sends the reply.
Requiring Approval
If your tool performs a destructive action, extend BaseTool and declare which operations require approval:
use Laraclaw\Tools\BaseTool;
class InvoiceTool extends BaseTool
{
protected array $requiresApproval = [
'delete' => 'Delete invoice {id}?',
];
// ...
}
The placeholder {id} is filled from the request parameters. When the agent calls a gated operation, the connector asks the user before the handler runs, and the paused run survives a worker restart or a deploy. See Confirmations for the full flow.
Approval works the same whether the tool was registered through config or through the registry.
Best Practices
- Keep descriptions concrete. "Get the current stock price for a ticker symbol" beats "Stock tool".
- Return strings the agent can reason about. Plain text or JSON are both fine. Avoid raw binary.
- Fail loudly, in plain English. Returning
"City 'Atlantis' not found."is more useful than throwing an exception. - Limit output size. The agent has a context window. If your data is large, summarize or paginate.
- One job per tool. Build five sharp blades, not one Swiss Army knife.