Threaded actor
The threaded actor is the verified, host-side identity that owns a conversation turn. It is the only place a tool may read identity from — never from LLM-supplied arguments, and never from static context.
This page explains why the contract is shaped the way it is, what guarantees the package enforces, and how to write tools that honour the rule.
The rule
Inside ChatbotTool::authorize() and ChatbotTool::handle(), you receive ?Authenticatable $actor as the first parameter. This is the actor reconstituted server-side from the verified envelope's userId via the configured auth guard's user provider.
public function authorize(?Authenticatable $actor, ToolInvocation $invocation): bool;
public function handle(?Authenticatable $actor, ToolInvocation $invocation): array|string;$actoris non-null for authenticated turns.$actoris null for guest turns — handle this explicitly.$actoris not carried on$invocation(no$invocation->actorproperty exists).
Why it's a parameter, not a property
If actor lived on $invocation alongside args and context, a careless tool could read identity from the wrong place — for example by doing $invocation->args['user_id'] ?? $invocation->actor->id. Putting $actor on the method signature makes that mistake structurally harder: there is no actor-shaped value in $invocation to fall back to.
See ADR-0003 for the full rationale.
What the package enforces
Identity-shaped argument blocking
At tool registration time, the package inspects the JSON schema returned by parameters(). If any property name matches an identity-shaped pattern, registration fails loudly with ForbiddenToolArgumentException.
Blocked names (case-insensitive):
user_id,userIdaccount_id,accountIdtenant_id,tenantIdactor_id,actorIdon_behalf_of,onBehalfOf- Common variants of the above
TIP
If your tool genuinely needs an entity ID that happens to look like an identity (e.g., customer_id for a B2B admin tool that looks up other customers' data), choose a name that doesn't trigger the pattern (e.g., customer_reference) and document the authorisation rule in the description.
Actor reconstitution
On every tool invocation, the runtime:
- Reads
userIdfrom the verified envelope. - Resolves it through the configured auth guard's user provider (
config('auth.defaults.guard')by default). - Passes the result as
$actor.
This means your handler always receives a fully-hydrated Authenticatable (typically your App\Models\User), not a bare ID.
Scoping data access by $actor
public function handle(?Authenticatable $actor, ToolInvocation $invocation): array
{
abort_if($actor === null, 401);
return Order::query()
->where('user_id', $actor->getAuthIdentifier())
->findOrFail($invocation->args['order_id'])
->toArray();
}The findOrFail scopes by $actor->getAuthIdentifier(), so even if the LLM passes someone else's order_id, the query returns no rows and 404s.
Guest turns
If your application allows unauthenticated users to chat, your tool must decide explicitly what to do:
public function authorize(?Authenticatable $actor, ToolInvocation $invocation): bool
{
return $actor !== null;
}Returning false from authorize() causes the invocation to short-circuit with a permission_denied outcome, persisted and surfaced as a tool_failed SSE event. The model is told the tool refused; it produces prose as if the data is unavailable.