Skip to content

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.

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.

Livewire drawer -> AssistantChat::send() -> RunTurn job (assistant queue)
-> MisterSmoothie::stream()
-> TurnRelay -> BroadcastFrames -> Reverb -> browser
  • Chat\AssistantChat is what the UI calls. It validates, checks assistant.use and ownership, claims the conversation’s turn in Chat\TurnState (one at a time, a second send throws TurnInProgress), and dispatches Jobs\RunTurn. It returns at once.
  • RunTurn is tried once on its own assistant queue connection, so a failed turn is reported, never re-run. It runs under its own worker, not Horizon’s lanes: queue:listen assistant in DDEV (.ddev/config.assistant.yaml, with reverb:start), and a process-managed queue:work in production.
  • Chat\TurnRelay batches 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 plus TurnState.
  • 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.

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. RunTurn sets the admin panel current and puts the user on its guard for the turn, forgetting them in a finally. The worker has nobody logged in otherwise.
  • Resources are the gate. PanelResources::available() is the resources whose canAccess() passes, through each resource’s policy. A key the user can’t open is refused exactly like an unknown one.
  • Rows come from getEloquentQuery(). So CustomerVisibility narrows a rep’s customers here as it does on screen. get_record also needs the policy’s view, 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\RecordFields removes each model’s $hidden, assistant.fields.never, anything matching assistant.fields.never_patterns (*token*, *secret*, *password*) and assistant.fields.capability entries 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\RecordRelations follows 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.

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:

  1. needsApproval() returns Approval::required($reason), with the bulk action’s preview as the reason, such as what would change and what would be skipped.
  2. A call the tool would refuse anyway returns false from needsApproval() and is refused by handle(), so the model corrects it before anyone is asked. Both use the same checks, so nothing that would change data skips the question.
  3. The SDK stops at the gated call, the turn ends paused, and the drawer shows Approve and Reject on the card.
  4. AssistantChat::decide() checks the answers match what the paused reply waits on, and queues RunTurn with the decisions. Sending a new message instead leaves the calls unmade.

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.

A read tool, say one that summarises a customer’s open orders:

  1. Put the logic in Data\, reaching records through RecordQuery (find(), query(), count()), so access stays the panel’s. Don’t write queries that skip getEloquentQuery().

  2. 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. ToolCards and the tool log treat a non-JSON result as refused. Report bad arguments with a ValidationException: the SDK hands its message back to the model to try again, whereas anything else ends the turn.

  3. Register it in MisterSmoothie::tools().

  4. Give it a plain label in the match in Chat\ToolCards, for the chat’s tool card. Without one it falls back to a headline of its name.

  5. Mention it in resources/views/instructions.blade.php if the model needs telling when to use it.

  6. Test it as different roles. tests/Feature/GetProductTest.php and ResourceToolsTest.php are the patterns. Tests fake the model with MisterSmoothie::fake(), and phpunit.xml sets 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).