Skip to content

Tools in practice

What came up when giving a model real actions in an application — creating records, changing a cart, posting comments. API reference: Tools & MCP, Tool choice & approval.

If the application already exposes a laravel/mcp server, its tool classes can be given to a task directly — no client connection, laravel/ai wraps them itself. Keep the permission gate the server applies:

public function tools(): array
{
return collect([TasksTool::class, CreateCommentTool::class, StartTimerTool::class])
->map(fn (string $class) => new $class())
->filter(fn ($tool) => $tool->shouldRegister()) // same visibility as on the MCP server
->values()
->all();
}

MCP resources don’t reach the model this way — AiPayload carries only tools. Data the server offers as a resource (the current user’s profile, reference lists) has to go into the system prompt or get a tool equivalent. If you reuse the server’s instructions as the system prompt, remove the sentences that point to resources instead of adding “ignore that” later — the earlier rule wins.

Tools usually call auth()->user(), policies and scopes. Where they run decides who that is:

CallWhere the tool loop runsauth() without the traitwith ActsAsDispatchingUser
AI::send() in a web requestthe requestthe logged-in userthe same user, the same instance
AI::send() in your own queued job / listenerthat jobwhatever the job setswhatever the job set at dispatch
AI::queue()the package worker (ProcessAiPayload)nobodythe user who dispatched it

Add the trait to a task whose tools or hooks act as the user (since 3.34):

use Fomvasss\AiTasks\Traits\ActsAsDispatchingUser;
class AssistantReplyTask extends AiTask
{
use ActsAsDispatchingUser;
}

It captures the guard, the user id and the app locale at dispatch, stores them with the run and applies them wherever the package runs the task’s code: tools()/toPayload() and the tenant/user resolution of send(), queue() and stream() (since 3.36), the provider call with its tool loop and approval checks, shouldRun(), postprocess(), onCompleted(), onFailed(), a retry after isAcceptable(), and a Retry from the dashboard or ai:retry — which then acts as the original user, not as whoever clicked. The user is re-read by id on that guard (made the default guard for the call), and everything is restored afterwards, also when the call throws, so the next job of the worker never inherits the user.

A task that runs on someone’s behalf with nobody logged in — dispatched from a job, a webhook, a listener — names that user instead of calling Auth::setUser() (since 3.36):

class AutoReplyTask extends AiTask
{
use ActsAsDispatchingUser;
public function __construct(private User $onBehalfOf, private Comment $comment) {}
protected function actingUser(): ?Authenticatable
{
return $this->onBehalfOf;
}
}

The user is then applied for the whole call — tools() included — and removed afterwards; ai_runs.user_id records them too.

Tools needing more than the user — a header, a cart, a country — extend the context:

public function executionContext(): array
{
return [...$this->traitExecutionContext(), 'country' => request()->header('X-Country')];
}
public static function withExecutionContext(array $context, \Closure $call): mixed
{
$previous = request()->headers->get('X-Country');
request()->headers->set('X-Country', $context['country'] ?? null);
try {
return static::traitWithExecutionContext($context, $call);
} finally {
request()->headers->set('X-Country', $previous);
}
}

with the trait imported as use ActsAsDispatchingUser { executionContext as traitExecutionContext; withExecutionContext as traitWithExecutionContext; }. The context is stored as JSON: scalars and arrays only. Values that belong to one tool (a chat id the tool reports to) can still go into the tool’s constructor — they’re serialized with the job.

Running AI::send() inside your own queued job after Auth::setUser($user) also works — the whole tool loop then runs in that job — but prefer actingUser() above: it removes the user when the call ends. Reset the user when the job ends: a Queue::before() listener in a service provider covers every job, including one that threw (a finally in the job alone doesn’t cover a job killed by a timeout) — but skip the sync connection: such a job runs inside the current request, and forgetting the user would log the request’s own user out mid-request (dispatchSync, the sync queue in tests):

Queue::before(function (JobProcessing $event): void {
if ($event->connectionName !== 'sync') {
Auth::forgetUser();
}
});

When tools act as a user, every tool must check access to every id it receives — a tool that checks only “may create tasks” but not “may access this project” becomes a hole the model will eventually walk through.

laravel/ai returns validation errors (ValidationException) to the model so it can fix its arguments. Any other exception — a findOrFail() on a wrong id, an authorization failure — fails the whole run with a generic error. Wrap tools so the model gets the error as a tool result and can recover:

class SafeTool implements Tool
{
public function __construct(private readonly Tool $tool) {}
public function handle(Request $request): Stringable|string
{
try {
return $this->tool->handle($request);
} catch (ModelNotFoundException) {
return json_encode(['success' => false, 'message' => 'Not found. Check the id and try again.']);
} catch (AuthorizationException) {
return json_encode(['success' => false, 'message' => 'Not allowed for this user.']);
} catch (\Throwable $e) {
report($e);
return json_encode(['success' => false, 'message' => 'The action failed.']);
}
}
public function name(): string { return ToolNameResolver::resolve($this->tool); }
public function description(): Stringable|string { return $this->tool->description(); }
public function schema(JsonSchema $schema): array { return $this->tool->schema($schema); }
}

Forgiving tool schemas help too — accept an id with or without a prefix, infer a type from the field that was given. Every rejected call is a wasted step.

By default the tool loop stops after round(number of tools × 1.5) steps, at most 25 (5 without tools). With one or two tools that’s 2–3 steps, and a chain like “find, then read, then act” is cut off silently with whatever text the model has at that point — finishReason tool_calls on the last step means it was cut.

A task with a narrow tool set and a multi-step job sets the budget itself (since 3.38):

public function maxSteps(): ?int
{
return 8;
}

max_steps in the AiPayload options does the same per call and wins over the method. Each step is a provider call, so the budget is also a cost ceiling.

Models claim actions they didn’t perform (“the timer is started”). Check the reply against AiResponse::$toolCalls: when a reply announces an action but no tool was called, run the task again with toolChoice() returning 'required' and a note in the prompt that the previous draft was rejected. If there’s still no tool call, tell the user honestly that it didn’t work.

Some providers reject tool_choice: required in reasoning mode with a 400 — catch it and retry with only the prompt instruction. Match the claim with word boundaries (\b...\b); a capabilities list like “I can add…” otherwise matches “added”.

Built on laravel/ai’s Approvable, see Tool approval. Lessons:

  • Gate MCP server tools on the wrapper, not on the tool. An MCP server tool returned from tools() is wrapped in McpServerTool automatically, and only the wrapper is asked — needsApproval() declared on the MCP tool itself is ignored and it runs without pausing. Wrap it yourself: (new McpServerTool($tool))->requireApproval('...'), or a McpServerTool subclass overriding needsApproval() when the answer depends on the call — see MCP server tools.
  • Validate before asking. Make needsApproval() return false when the call is invalid anyway (missing item, wrong quantity) — the error goes back to the model at once. Otherwise the customer confirms, the tool fails, the model retries with a new call id, and the customer is asked to confirm the same thing again.
  • Count consecutive failures of a tool (in cache, per chat) and after a few tell the model to offer a human.
  • Resume with AI::resume() / AI::queueResume(), keeping only $response->runId with your chat — the package stores the paused turn and refuses a second, expired or tool-less resume. See Resuming.
  • Render the confirmation text yourself from the pending call’s arguments rather than trusting the model’s wording.
  • Build the resumed history as of the pause in toPayload() when resumingRun() is set — cut your chat at the message that led to the pause.
  • Classify the customer’s answer cheaply first — exact “yes”/“no” matches in the supported languages — and call an AI classifier only for the rest.

Even with an enum schema, models occasionally return an id that wasn’t in the list. Check every id in postprocess() against the candidates you sent and drop the unknown ones.