Assistant
Mister Smoothie is the admin panel’s AI assistant, in the assistant module
(app-modules/assistant). Staff guidance is in assistant.
app-modules/assistant/ARCHITECTURE.md has the details of frames, the drawer and catch-up.
The agent is built on the Laravel AI SDK (laravel/ai), runs on a queue, and streams its replies to
the browser over Reverb.
What is enabled today
Section titled “What is enabled today”The module is on: AssistantFilamentPlugin is registered in AdminPanelProvider, and it adds the
topbar button and drawer on every admin page for staff with assistant.use.
| Setting | Today |
|---|---|
| Who can chat | assistant.use: super admin, manager, sales rep, accounts and catalog editor (config/roles.php) |
| Who can audit | assistant.audit, given to no role, so super admin only |
| Provider and model | ASSISTANT_PROVIDER and ASSISTANT_MODEL, defaults in config/assistant.php, the key from the SDK’s ai config (OPENAI_API_KEY and so on) |
The agent’s tools, from Agents\MisterSmoothie::tools():
| Tool | Class | Kind |
|---|---|---|
list_resources |
Tools\ListResources |
Read |
describe_resource |
Tools\DescribeResource |
Read |
query_records |
Tools\QueryRecords |
Read, at most 50 rows |
count_records |
Tools\CountRecords |
Read, optionally grouped |
get_record |
Tools\GetRecord |
Read, with relations |
get_product |
Tools\GetProduct |
Read: prices through ProductPricer, stock, branding |
site_summary |
Tools\SiteSummaryTool |
Read |
read_doc |
Tools\ReadDoc |
Read: one page of the user guide, listed in his instructions |
adjust_grid_prices |
Tools\AdjustGridPrices |
Write, asks first |
set_margin |
Tools\SetMargin |
Write, asks first |
Those two price tools are the only writes. There is no generic record editor.
A turn
Section titled “A turn”Livewire drawer -> AssistantChat::send() -> RunTurn job (assistant queue) -> MisterSmoothie::stream() -> TurnRelay -> BroadcastFrames -> Reverb -> browserChat\AssistantChatis what the UI calls. It validates, checksassistant.useand ownership, claims the conversation’s turn inChat\TurnState(one at a time, a second send throwsTurnInProgress), and dispatchesJobs\RunTurn. It returns at once.RunTurnis tried once on its ownassistantqueue connection, so a failed turn is reported, never re-run. It runs under its own worker, not Horizon’s lanes:queue:listen assistantin DDEV (.ddev/config.assistant.yaml, withreverb:start), and a process-managedqueue:workin production.Chat\TurnRelaybatches stream events into frames (text,tool,turn_end) on a private channel per conversation. Reverb rejects large messages, so text is split.- Reverb keeps no history. A browser that reconnects rebuilds from
AssistantChat::snapshot(), which is the saved messages plusTurnState. - A turn ends finished, paused, stopped or failed, always with one
turn_end.
Conversations use the SDK’s own tables. Ownership is AssistantChat::owns(), not a policy, because
the super admin passes every policy and conversations stay private even from them.
Data access: policies, no SQL
Section titled “Data access: policies, no SQL”The tools read the panel’s data through the panel’s own rules, so Mister Smoothie sees exactly
what the staff member would. There is no SQL tool. Every read goes through Data\RecordQuery and
Data\PanelResources:
- As the user.
RunTurnsets the admin panel current and puts the user on its guard for the turn, forgetting them in afinally. The worker has nobody logged in otherwise. - Resources are the gate.
PanelResources::available()is the resources whosecanAccess()passes, through each resource’s policy. A key the user can’t open is refused exactly like an unknown one. - Rows come from
getEloquentQuery(). SoCustomerVisibilitynarrows a rep’s customers here as it does on screen.get_recordalso needs the policy’sview, and a record they can’t see reads like a missing one. - Fields are filtered. A screen can leave a column out, but the model would get them all.
Data\RecordFieldsremoves each model’s$hidden,assistant.fields.never, anything matchingassistant.fields.never_patterns(*token*,*secret*,*password*) andassistant.fields.capabilityentries the user lacks. Filters, sorts and grouping accept only visible columns, so a withheld field can’t be probed through a filter. - Relations follow the same rules.
Data\RecordRelationsfollows a relation through the related resource’s scoped query. A has-many onto a model with no resource at all (quote lines, variants) counts as part of the record.
The one piece of SQL text in RecordQuery is the date grouping expression, built from a checked
column and a fixed format.
The model provider receives what the tools return, so a field staff may see but that shouldn’t
leave belongs in assistant.fields.never. Customer contact details are sent.
Approvals
Section titled “Approvals”A write tool is a verb that runs the same class as the panel’s action, so there is one save
path. adjust_grid_prices and set_margin run PriceAdjustmentOperation and SetMarginOperation
inline through Data\ProductChanges, at most 200 products per call, with BulkPricing deciding
what each applies to. Each product must pass the policy’s update. Percentages are limited to -50
to +100 per call.
A write tool implements the SDK’s Approvable:
needsApproval()returnsApproval::required($reason), with the bulk action’s preview as the reason, such as what would change and what would be skipped.- A call the tool would refuse anyway returns false from
needsApproval()and is refused byhandle(), so the model corrects it before anyone is asked. Both use the same checks, so nothing that would change data skips the question. - The SDK stops at the gated call, the turn ends
paused, and the drawer shows Approve and Reject on the card. AssistantChat::decide()checks the answers match what the paused reply waits on, and queuesRunTurnwith the decisions. Sending a new message instead leaves the calls unmade.
The audit trail
Section titled “The audit trail”Listeners\RecordToolCalls writes assistant_tool_calls: user, conversation, tool, arguments,
outcome, row counts, timing. Never the result, so customer data isn’t copied into a second
table. It outlives the conversation and is pruned after assistant.tool_log_days.
Listeners\RecordUsage writes tokens to assistant_usage. Both are read-only screens behind
assistant.audit, at System > Configuration > Assistant.
Adding a tool
Section titled “Adding a tool”A read tool, say one that summarises a customer’s open orders:
-
Put the logic in
Data\, reaching records throughRecordQuery(find(),query(),count()), so access stays the panel’s. Don’t write queries that skipgetEloquentQuery(). -
Add a thin tool in
src/Tools/:class CountRecords implements Tool{public function __construct(private RecordQuery $records) {}public function name(): string { return 'count_records'; }public function description(): string { return 'Counts the records of one resource ...'; }public function handle(Request $request): string{$arguments = $request->validate(['resource' => ['required', 'string'],...RecordQuery::filterRules(),]);return json_encode($this->records->count($arguments['resource'], $arguments['filters'] ?? []));}public function schema(JsonSchema $schema): array{return ['resource' => $schema->string()->description('...')->required()];}}Always answer in JSON.
ToolCardsand the tool log treat a non-JSON result as refused. Report bad arguments with aValidationException: the SDK hands its message back to the model to try again, whereas anything else ends the turn. -
Register it in
MisterSmoothie::tools(). -
Give it a plain label in the
matchinChat\ToolCards, for the chat’s tool card. Without one it falls back to a headline of its name. -
Mention it in
resources/views/instructions.blade.phpif the model needs telling when to use it. -
Test it as different roles.
tests/Feature/GetProductTest.phpandResourceToolsTest.phpare the patterns. Tests fake the model withMisterSmoothie::fake(), andphpunit.xmlsets the null broadcaster.
A write tool also implements Approvable with InteractsWithApprovals, and must run the panel’s
own operation, not its own save. Use Tools\SetMargin as the template. The SDK’s fake gateway
doesn’t run tools on a resume, so test the tool itself (PriceToolsTest) separately from the pause
(ApprovalsTest).