Context
Server tools exist: gen projects every L0 and L1 query and every action a user can perform (unless it saystool: false with a reason, 0360 decision 4) into the tool table, with the
risk of its permission and the approval modes of 0360; AgentRuntime in @stackra/ai runs the loop, parks a run on
an approval and resumes it; Facts give record context on the server; @stackra/mcp builds the tool and ui://
resource tables from the manifests. What is missing is everything on the client. The agent cannot open a screen,
filter a list, prefill an action’s form, switch the theme or walk a user through a feature, because 0245 decision 3
allows only tools that project an action or a query. It does not know which screen the user is on. Saved views exist
as a model (panel.stored_view, 0271) but not as actions, so the agent cannot save one. There is no React or React
Native entry, no sidekick and no tour. Hand-written client tools would drift from the screens they drive, so the gap
must be closed the way server tools were: by generation from what the app already declares.
Decision
Client tools
-
A client tool is the projection of a frontend declaration, generated, never hand-written. 0245 decision 3
reads: “A tool is the projection of a capability or of a frontend declaration, generated, never hand-written.”
ToolSourcegainsClient. gen collects the client tools of every module an app member mounts into its#stackra/client-tools(decision 16), from:Names follow the action key grammar (orders.list.filter), keyed by the app member. A declaration opts out withai: false. The table carries each tool’s input schema, compiled from the declaration’s types as action inputs are. -
A client tool changes only the viewer’s screen. Its risk is L0 and it needs no approval, under every mode. It
never calls a server action: a write reaches the server only as a server tool under its own approval, or as a
.prefillthat the human submits. A.prefillis how “propose” (0398) looks on a screen. - The tools offered are the session’s. The sidekick opens a session with its surface descriptor: the app member, its client tool table hash, the route and the user’s locale. The runtime offers the model the server tools the caller may call (as today) plus the client tools of that surface, filtered by the route permissions and the panel resource permissions the caller holds. A surface with no screen (an MCP host, WhatsApp, a background run) is offered no client tools.
-
Hand-off and result. A client tool call parks the run as an approval does: the runtime records the call,
emits a
client_toolpart on the run’s stream to the one surface that opened the turn (by its surface id), and waits. The surface runs the generated handler and posts the result to theai.run.client_resultaction (PUT /runs/:runId/client-result, input{ toolCallId, result | refusal }, idempotent bytoolCallId); the runtime validates the result against the tool’s output schema and resumes the loop. No result within 30 seconds (or the surface closed) resumes the loop with the refusalclient_unavailable, which the model sees as a tool error. A result is never trusted as data about records: it reports only what the screen did.
Page context
-
A route declares its page context. A route or panel page declares which context it publishes:
record(a model and the param that holds its public id),selection,filters,view,modal. Panel pages publish theirs without a declaration. The SDK keeps the currentPageContextand sends it with each turn:{ app, route, params, record?: PublicId, selection?: { ids ≤ 200, count, filter? }, filters?, view?, modal?: { action, fields } }. It holds ids and declared keys only, never record field values, and never a field the user cannot see. -
The server turns context into facts under the caller’s permissions. The runtime drops any id the caller cannot
read, loads each remaining record through its
Factsprovider, applies the field allow-list of the data class (0360 decision 15), and adds the result to the turn under the token budget. Client-sent context is a pointer, not evidence.
Personalisation as actions
-
Personalisation is ordinary actions, tools by default (0360 decision 4), so the agent gets them as server tools under the same
approvals, and every change is audited and undoable as any action is. Each lives in the package that owns its model:
Each package’s node module is mounted by the OS service that already mounts it; these tables live in that service’s database, under
BelongsToTenant. - The floor still applies. A personal change stays below every floor item. A change that alters what other people see (a shared view, a tenant dashboard, a scope setting) keeps its own risk, and a live tenant’s configuration reaches live only through promotion (0360 decision 8, item 4).
- Preview, then persist. The agent may preview a theme or a layout with a client tool; it keeps the change only by calling the action. The sidekick shows the action as any other tool call.
Tours
-
A tour is a document of kind
Tourin@stackra/experience: an ordered list of steps, each one client-tool call (navigate.*,ui.point,*.prefill,*.filter) with a text (an i18n key, or generated text for a generated tour), an anchor and an advance rule (click,route,tool_result,manual). A tour references only declared client tools and anchors, so it is data that references registered code (allowed by 0227); the server validates every step against the app’s client tool table, and a step whose tool vanished fails validation at gen. -
The agent can start, generate and personalise a tour.
tour.startruns a stored tour. The agent may compose a tour for the question in front of it: the composed document is validated as a stored one, runs once, and is kept only throughexperience.tour.save(L1). Progress is per user (experience.tour_progress, L0): completed and skipped steps, so a tour resumes and the agent skips what the user already did. Onboarding is a stored tour bound to a role and a first-sign-in trigger in settings; a tenant may reorder or hide steps of a shipped tour through an overlay, never add a step that calls an undeclared tool.
The SDK and the sidekick
-
@stackra/aigains./reactand./nativeentries (0294 decision 15):AiProvider,useAgent(turns, streaming, stop, retry),usePageContext,useClientTools(registers the generated handlers),useVoiceInput. The stream is the AI SDK UI message stream over Streamable HTTP from the ai service; the client library is@ai-sdk/react, the same vendor as the server’s AI SDK core (catalog entry needs the owner). -
The sidekick is one component contract with a web and a native implementation on
@stackra/ui(the shared contract; no shared implementation, 0165): composer, voice button, streamed answer, tool-call rows, the approval card, the plan view, the mode switch of 0360 and the AI marker. It runs in the console, the mobile staff app and the till. Because all AI code is free (0155 decision 3),AssistantPanel,ApprovalCardandPlanViewmove from@stackra-pro/ui-prointo@stackra/ui. -
Generative UI cards are declared once and rendered on three kinds of surface. A
Cardis keyed by a tool outputSchemaand has a web component, a native component and a message projection (text, an image and at most three buttons, each a tool call or a link). The sidekick renders the web or native component for a tool result of that schema; gen builds the web component into theui://<module>/<name>single-file bundle of 0348 decision 2 for MCP hosts; WhatsApp and SMS take the message projection. A tool result with no card renders as a summary. -
Voice is the speech port of 0376.
useVoiceInputcaptures audio (MediaRecorder on web; thenative-audioExpo module package on mobile and the till, per 0478) and streams it to the ai service, which runsSpeechService.listenfor the tenant’s region and metered onspeech.seconds. No speech provider is named in client code.
Modularity
- A tool belongs to the module that declares it. Server tools come from a module’s own actions and queries;
client tools come from a module’s own routes, resources, placements, anchors, tours and cards, whether that module
lives in an app, a service or a package (0259, 0294 decision 13). Each module’s rows carry its module key, ship
with the module and appear in its member’s
stackra.manifest.json; nothing outside the module lists, wraps or edits them. gen only collects: an app’s client tool table is the union of the modules it mounts, the gateway’s server tool table is the union of the deployed services’ manifests (0492 decision 3). Removing a module removes its tools, and a module never declares a tool for another module’s capability. Isolation at run time stays the service and the module boundary of 0294 decision 24, not a container per module: the only containers added by this work are the tools-stack images of 0492.
Consequences
- An app gets AI over its own screens by declaring routes, resources and anchors it already has; nobody writes a tool. A renamed route or action changes the tool table and fails the tours that use it at gen, not in front of a user.
- The runtime gains a third park reason (client tool) beside approval and plan; the run store records it.
- Panel, settings and experience each gain actions and a small amount of model work; the OS services that mount them carry the migrations.
ui-proloses three components to the freeuipackage; their tests move with them.- The sidekick depends on the ai service (0492) for every turn; with no ai service deployed there is no sidekick.
Enforcement
- planned — STA-1631: gen fails on a client tool name collision, a
.prefillof an action that is not a tool (tool: false) or has no placement, an anchor key used by a tour and not declared, and a routecontext.recordparam that is not a public id. - planned — STA-1631: the runtime test that a client tool call from one surface is never delivered to another, and
that a
client_resultfor an unknown or already-answeredtoolCallIdis refused. - planned — STA-1631: an
ai: falsedeclaration is absent from the generated table (gen snapshot test). - planned — STA-1633: the tour validator refuses a step whose tool is not in the app’s client tool table.
ToolSourcestays a closed enum (packages/application/ai/src/core/enums/tool-source.enum.ts, its test).
Supersedes and sources
Supersedes: none. Amends 0245 decision 3 (client tools, L0, no approval); server tool exposure stays 0360 decision 4. Sources: the
leader’s brief of 2026-10-30 (STA-1629); packages/application/ai, packages/developer/mcp,
packages/experience/panel (StoredView), packages/communication/integrations (SpeechService) on core main
6f3536ed.