Agents
An LLM can operate a Sygnal app the way a person does: by running its actions. The agent static says which actions a model may run, what each one takes, and what the model sees of the component’s state. Nothing else is exposed: an action that isn’t listed is not a tool.
One declaration serves every kind of agent:
- the in-app assistant: the
chatbehavior, a chat model that operates the component it lives in; - the command bar: one line of text, one action, picked by a decision model;
- the browser’s own agent, through WebMCP (experimental);
- an MCP host showing your app as an MCP App;
- a coding agent, through the dev server MCP endpoint;
- your tests:
t.tools(),t.callTool()andt.agentContext().
Everything on this page is imported from sygnal/ai, except abort (from sygnal). An app that doesn’t use it pays nothing.
The agent static
Section titled “The agent static”import { z } from 'zod'import { Collection } from 'sygnal'import TodoItem from './TodoItem.jsx'
export default function TodoApp({ state }) { return ( <main> <ul className="todos"><Collection of={TodoItem} from="todos" /></ul> <button type="button" className="clear" disabled={!state.todos.some((t) => t.done)}>Clear done</button> </main> )}
TodoApp.initialState = { todos: [], filter: 'all', nextId: 1 }
TodoApp.intent = ({ DOM }) => ({ CLEAR_DONE: DOM.click('.clear') })
TodoApp.model = { ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }), SET_FILTER: (state, filter) => ({ ...state, filter }), CLEAR_DONE: (state) => ({ ...state, todos: state.todos.filter((t) => !t.done) }),}
TodoApp.agent = { name: 'todos', // tool names: todos_add, todos_set_filter, ..., todos_read description: 'The todo list', untrusted: true, // read() returns text the user typed // what the model sees of this component read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }), actions: { ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') }, SET_FILTER: { description: 'Which todos to show', input: z.enum(['all', 'active', 'done']), idempotent: true }, CLEAR_DONE: { description: 'Delete every todo that is done', consequential: true, // the user confirms first when: (state) => state.todos.some((t) => t.done), // offered only while it can do something }, },}ADD and SET_FILTER have no intent trigger here: they exist for agents, and an agent.actions entry counts as a trigger for SYG102.
| Key | What it does |
|---|---|
name | The tool name prefix: todos gives todos_add, todos_set_filter, todos_clear_done (the action in snake case) and todos_read. Letters, digits and _. Two declarations with one name are SYG440 |
description | What the component is, for the model |
read(state) | What the model sees of the component: a projection, not the state. Keep ids in it, so the model can name things. Without read, agents see nothing of the state |
label(state) | A Collection item’s label for the model |
untrusted | read returns text users typed (see Security). Unset, Sygnal infers it from the strings in the projection |
actions | The only actions agents may run, by action name |
queries | Read-only tools that take an input: data too large for read, or answers computed from the state (Queries) |
Each entry of actions:
| Key | What it does |
|---|---|
description | What the action does, in the app’s own words (at most 500 characters for WebMCP) |
input | The schema of the action’s data. Without one the action takes no arguments |
consequential | Ask the user before running it |
when(state) | The tool is offered only while this is true |
idempotent | A call that changes nothing is a success ({ ok: true, unchanged: true }), not an error |
until | The call answers once the action’s result is in state: a resource name, or a function of the state (Waiting for a result) |
timeout | With until: how long to wait, in ms (default 10,000) |
Input schemas
Section titled “Input schemas”input is any Standard Schema that also implements Standard JSON Schema: Zod 4.2+, ArkType 2.1.28+, or Valibot through toStandardJsonSchema(). The model gets the schema’s JSON Schema form (the input side, normalized for every provider), and Sygnal validates the arguments with the schema itself before anything runs. A schema whose root isn’t an object (z.string(), z.enum([...])) is sent as { value }; your reducer still gets the plain value.
Plain JSON Schema goes through jsonSchema(), which checks the common keywords itself (type, enum, required, properties, minLength, pattern, …):
import { jsonSchema } from 'sygnal/ai'
const input = jsonSchema({ type: 'object', properties: { text: { type: 'string', minLength: 1 } }, required: ['text'] })With ArkType, .describe() on a field (or on a root that isn’t an object) also replaces what its validation messages say is expected: type({ id: type('string').describe('the todo id') }) rejects { id: 3 } with id must be the todo id (was a number), not id must be a string (was a number), and that message is what the model gets back in issues (the call rules). A .describe() on an object leaves its fields’ messages alone. To keep both, write the description as the expectation (.describe('the todo id, a string')), or set the description and the expected text separately with .configure():
import { type } from 'arktype'
const input = type({ id: type('string').configure({ description: 'The todo id', expected: 'a string' }) })// { id: 3 } → "id must be a string (was a number)"; the model still sees the description.configure() also copies expected into the JSON Schema as a non-standard keyword (strictSchemas moves it into the description; a provider that rejects unknown keywords may refuse the schema otherwise), so a description phrased as the expectation is the safer choice. Zod’s .describe() doesn’t change its messages.
A schema with no JSON Schema form at all (a Zod z.date(), a refine on the root) is SYG240: the tool isn’t offered, and t.tools() lists it with its error. A schema that converts with losses (a refinement inside an object) is SYG243: the model doesn’t see that part, but it is still checked.
In TypeScript, on a component typed with its actions (TypeScript), input is required for an action that takes data, and its schema’s output must fit that data.
What agents see
Section titled “What agents see”Tools. One per listed action and query, plus <name>_read when the declaration has a read. The todo app above offers:
| Tool | Input | Hints |
|---|---|---|
todos_read | none | read-only |
todos_add | { value: string } | |
todos_set_filter | { value: 'all' | 'active' | 'done' } | |
todos_clear_done | none; only while a todo is done | consequential |
todo_toggle, todo_remove | { id }, from TodoItem’s declaration (below) | todo_remove: consequential |
Only the components on the page count: a component that isn’t rendered (a hidden Switchable page, a closed panel) offers no tools. The list follows the app after every render.
State. The read projections, kept current after every render. The research behind this feature measured why it matters: two small local models operating a todo app through validated tools got 0 of 10 tasks right without the state and 10 of 10 with it. Without the ids in front of it, a model guesses them. Each consumer delivers the projections in its own way: the chat behavior sends them with every request, WebMCP has the read tool plus a summary in the tool descriptions, and the command bar puts them in its decision.
The call rules
Section titled “The call rules”Every call, from any kind of agent, goes through the same steps:
- Validation. The arguments are checked against
inputbefore anything runs. A failure comes back to the model as{ ok: false, error, issues }and never reaches a reducer. Before validating, numeric and boolean strings are repaired where the schema says number, integer or boolean ("3"→3), since small models often send them. - Availability. A tool whose
whenis false now gets{ ok: false, error: 'todos_clear_done is not available now' }. - Confirmation. A
consequentialaction waits for the user (each consumer asks in its own way). A declined call is{ ok: false, error: 'the user declined' }. The target andwhenare checked again after the answer. - Dispatch. The action runs with
cause: 'agent', through the same queue, reducers and render as a click. It shows int.actions, the DevTools action log and the dev MCP endpoint’srecent_actionswith that cause. - The result, after the render:
{ ok: true, state }, wherestateis the nearestreadprojection (the component’s, or for an item, its own or its list’s). An action withuntilanswers later, once its result is in state.
No-op detection. A call succeeds when one of the action’s handlers had an effect: a reducer changed the state, a driver sink got a value, or an EFFECT ran. A call that changed nothing is reported as a failure, not a false success. Here, SET_FILTER to the filter already set, on an action without idempotent:
{ ok: false, error: 'SET_FILTER changed nothing (already so, or the input matched nothing)', state: {…} }A model that toggled the wrong id learns about it this way. For an action where “already so” is fine (setting a filter to its current value), mark it idempotent: true, and the call is { ok: true, unchanged: true, state }.
Refusals. A reducer that returns ABORT changes nothing, so the call reports “changed nothing”. To tell the model why, return abort(reason) instead: the action is aborted as with ABORT, and the reason goes to the model.
import { abort } from 'sygnal'
TodoApp.model = { ADD: (state, text) => state.todos.length >= 50 ? abort('the list is full (50 todos); remove some first') : { ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 },}// → { ok: false, error: 'ADD was refused: the list is full (50 todos); remove some first', state }A refusal is reported even on an idempotent action.
One at a time. Calls run serially, in the order the model made them, so each one sees the previous one’s result. A model that sends three calls at once gets three results computed in sequence.
Waiting for a result: until
Section titled “Waiting for a result: until”Some actions only start something: a search sets the query, and a resources entry fetches the results. Right after the action, the results aren’t there yet, so the call would answer with status: 'loading', and the model would have to guess or ask again. With until, the call answers once the result is in state:
import { z } from 'zod'
export default function Shop({ state }) { const { status, data } = state.search return ( <section> {status === 'loading' && <p>Searching…</p>} <ul className="results">{(data ?? []).map((p) => <li key={p.id}>{p.name}</li>)}</ul> </section> )}
Shop.initialState = { query: '' }
Shop.resources = { search: (state) => state.query && { url: '/api/search', query: { q: state.query } } }
Shop.model = { SEARCH: (state, query) => ({ ...state, query }) }
Shop.agent = { name: 'shop', description: 'The product search the user sees', read: (state) => ({ query: state.query, status: state.search.status, results: (state.search.data ?? []).map(({ id, name }) => ({ id, name })) }), actions: { SEARCH: { description: 'Search the catalog; the results show on the page', input: z.string().min(1), until: 'search' }, },}shop_search({ value: 'red shoes' }) now answers with the results the user sees, { ok: true, state: { query: 'red shoes', status: 'success', results: [...] } }, or with status: 'error' when the request failed.
until: 'search'names one of the component’s resources. The call answers once that resource is neither loading nor refreshing, so akeepPreviousrequest or a refetch, which keep the status and setrefreshing, are waited for too. A name the component’sresourcesdoesn’t have is SYG248: the call answers at once.until: (state) => booleanis for a result a reply action writes (a driver request with anokaction). The action’s own reducer must mark it pending,status: 'loading', so the test is false right after the action:until: (state) => state.status !== 'loading'. A throwinguntilends the wait (SYG241).- The test runs after the action’s render and after every render that changed something. A result already in state answers at once.
- No result within
timeout(default 10 s):{ ok: true, pending: true, note, state }. The note tells the model the result will be in the app state later, where thechatbehavior’s next request shows it. - Calls run one at a time, so a waiting call delays the model’s next one. The
chatbehavior ends the wait when the user presses Stop or sends a new message (the call answerspending), andagentTools()hascancel()for other consumers. - An item that removes itself while its call waits answers
{ ok: true, removed: true, state }.
The side effect stays where it was: the request goes through resources or a driver, and a test answers it with the fetch fake, await t.respond('HTTP', [...]). t.callTool() waits for it like any agent.
Queries
Section titled “Queries”read goes to the model whole, with every request. That suits a summary, not a table of 5,000 invoices: sending them all costs tokens on every turn, and a model reads a long list badly anyway. A query is a read-only tool that takes an input and answers from the state: the model asks for the overdue invoices of one customer and gets those.
import { z } from 'zod'
export default function Invoices({ state }) { return <p className="count">{state.invoices.length} invoices</p>}
Invoices.initialState = { invoices: [] }
const Filter = z.object({ customer: z.string().optional().describe('The customer name'), overdue: z.boolean().optional(),})
Invoices.agent = { name: 'invoices', description: 'The invoices', untrusted: true, read: (state) => ({ count: state.invoices.length }), // the summary, on every request queries: { // invoices_find({ customer?, overdue? }) find: { description: 'The invoices matching a filter, at most 50', input: Filter, run: (state, { customer, overdue }) => state.invoices .filter((i) => (customer === undefined || i.customer === customer) && (overdue === undefined || i.overdue === overdue)) .slice(0, 50), }, // invoices_total() total: { description: 'The sum of all invoices', run: (state) => state.invoices.reduce((sum, i) => sum + i.total, 0) }, },}| Key | What it does |
|---|---|
description | What the query returns, for the model |
input | The schema of its input, as for an action (Input schemas). Without one the query takes no arguments |
when(state) | The tool is offered only while this is true |
run(state, input) | The result: a pure, synchronous function of the component’s state now and the validated input |
- A query is a tool named
<name>_<query>, marked read-only. It is offered next to the actions, to every kind of agent (the command bar, which picks one action, leaves read-only tools out). - A call validates the input and checks
when, as for an action, then returns{ ok: true, result }with whatrunreturned. Nothing is dispatched, nothing is confirmed, and nothing renders: a query can’t change the app. - It must be synchronous. A
runthat returns a promise (anasyncfunction, afetch) fails the call and is SYG246, as is a query withoutrun. Data that has to be loaded belongs to an action: its model entry asks for it (aresourcesentry or a driver request), anduntilmakes the call answer with the result. - A throw fails the call with its message and is SYG241.
- Actions and queries share the tool names. A query whose snake-case name is an action’s (
findnext toFIND) is SYG247, and only the action is offered. - On a Collection item component, a query gets the same key parameter as the item’s actions (
contact_notes({ id, word })), and answers from that item’s state. - Results are data. When they hold text users typed, set
untrusted: trueon the declaration: WebMCP then marks the queries withuntrustedContentHinttoo (Security). Keep results small (filter, then cut): WebMCP cuts a result over 1,500 characters of JSON (SYG242).
In TypeScript, run’s state is typed from the component; type the input yourself, run: (state, input: z.infer<typeof Filter>) => …, since it isn’t inferred from input.
Collection items
Section titled “Collection items”An item component’s actions don’t become one tool per item. They become one tool per action, with a parameter that names the item: an enum of the live keys (the items’ ids), routed to the item with that key.
export default function TodoItem({ state }) { return ( <li> <label><input type="checkbox" className="done" checked={state.done} /> {state.text}</label> <button type="button" className="remove" aria-label={`Remove ${state.text}`}>×</button> </li> )}
TodoItem.intent = ({ DOM }) => ({ TOGGLE: DOM.change('.done'), REMOVE: DOM.click('.remove') })
TodoItem.model = { TOGGLE: (state) => ({ ...state, done: !state.done }), REMOVE: () => undefined,}
TodoItem.agent = { name: 'todo', // todo_toggle({ id }), todo_remove({ id }) description: 'One todo', label: (state) => state.text, // "buy milk" for the item with id 1 (see below) actions: { TOGGLE: { description: 'Mark the todo done, or not done again' }, REMOVE: { description: 'Delete the todo', consequential: true }, },}(This block is a module the demos on this page import, ./TodoItem.jsx.)
- The parameter is
id, oritemwhen the action’s input has its ownidfield. label(state)gives each key a label, so a model can map “the milk one” to1. Labels are usually text users typed, so Sygnal treats them as user text: they don’t go into the tool’s schema, which models read like instructions. The parameter lists the ids only (Which todo (ids 1, 2; …)), and the labels travel as data: in thechatbehavior’s app state astodo_labels({ "1": "buy milk", "2": "water plants" }), in the command bar’s decisionstate.labels. When the labels are your app’s own words,untrusted: falseon the item declaration keeps them in the description (Which todo (1: buy milk; 2: water plants)). See Security.- The keys come from the items’
state.id. Items without ids, or two items with one id, are SYG441. - An unknown key gets the list of live keys back:
{ ok: false, error: 'no todo with id 7; ids: 1 (buy milk), 2 (water plants)', keys: [1, 2] }.
Items hidden by a filter. A <Collection filter={...}> doesn’t render the items it filters out, so they have no instance and no tool. A call for one gets the todo with id 3 is hidden by the current filter. When agents should reach every item whatever the filter shows, give the list owner a by-id action:
import { z } from 'zod'import { abort } from 'sygnal'
TodoApp.model = { SET_DONE: (state, { id, done }) => state.todos.some((t) => t.id === id) ? { ...state, todos: state.todos.map((t) => (t.id === id ? { ...t, done } : t)) } : abort(`no todo with id ${id}`),}
TodoApp.agent = { name: 'todos', read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }), actions: { SET_DONE: { description: 'Mark a todo done or not done, including todos the filter hides', input: z.object({ id: z.number().int(), done: z.boolean() }), }, },}The owner’s read lists every todo, so the model has the ids either way.
An in-app assistant: chat
Section titled “An in-app assistant: chat”The chat behavior is an assistant that operates the component it is used in. It sends the conversation to the chat driver with the agent tools of its host and of the host’s descendants on the page, runs the model’s tool calls through the call rules, sends the results back, and repeats until the model answers without a tool call. The driver, its transports and streaming are in AI Chat; the behavior runs the tool loop you would otherwise write yourself.
The demo below asks its first question as soon as it shows. The “model” is the demo server’s POST /v1/responses route, which answers in the Open Responses format a real model streams. It reads the app state the behavior sends and picks a tool by keyword: no LLM, no key. Ask it to add call mom, to mark something done, or to remove buy milk (consequential: you get Allow and Deny).
import { encodeOpenResponses } from 'sygnal/ai'
const words = (text) => text.match(/\S+\s*/g)const reply = (chunks) => ({ sse: encodeOpenResponses(chunks), delayMs: 300, chunkMs: 40 })const textOf = (item) => (typeof item.content === 'string' ? item.content : (item.content ?? []).map((c) => c.text).join(''))
export default { 'POST /v1/responses': ({ json }) => { const last = json.input.at(-1) // a tool ran: answer with its result if (last.type === 'function_call_output') { const result = JSON.parse(last.output) if (!result.ok) return reply(words(`That did not work: ${result.error}.`)) const open = (result.state?.items ?? []).filter((t) => !t.done).length return reply(words(`Done. ${open} todo(s) still open.`)) } // the app state: a message the chat behavior sends before the user's, JSON between <app-state> tags const stateMessage = json.input.find((item) => textOf(item).includes('<app-state>')) const app = JSON.parse(textOf(stateMessage).split('<app-state>')[1].split('</app-state>')[0]) const asked = textOf(last) const named = app.todos.items.find((t) => asked.toLowerCase().includes(t.text.toLowerCase())) if (/^add /i.test(asked)) return reply([{ toolCall: { name: 'todos_add', input: { value: asked.slice(4) } } }]) if (named && /remove|delete/i.test(asked)) return reply([{ toolCall: { name: 'todo_remove', input: { id: named.id } } }]) if (named) return reply([{ toolCall: { name: 'todo_toggle', input: { id: named.id } } }]) return reply(words('Try "add call mom", "buy milk is done" or "remove buy milk".')) },}import { z } from 'zod'import { Collection } from 'sygnal'import { chat, messageText } from 'sygnal/ai'import TodoItem from './TodoItem.jsx'
// the tools a message used, after its textconst toolsOf = (m) => m.parts.filter((p) => p.type.startsWith('tool-')).map((p) => ` [${p.type.slice(5)}]`).join('')
// The assistant's markup: a plain function the view calls, so the behavior's selectors// (.ask, .prompt, .approve, ...) are in the host's own viewfunction assistantPanel(a) { const busy = a.status === 'submitted' || a.status === 'streaming' return ( <aside className="assistant"> <ol className="conversation" aria-live="polite"> {a.messages.map((m, i) => <li key={i} className={m.role}><b>{m.role}:</b> {messageText(m)}{toolsOf(m)}</li>)} {a.draft && <li className="assistant"><b>assistant:</b> {a.draft}</li>} </ol> {a.pending && ( <div role="alertdialog" aria-label="Confirm"> <p>The assistant wants to: {a.pending.description} ({a.pending.label})</p> <button type="button" className="approve">Allow</button> <button type="button" className="deny">Deny</button> </div> )} {a.error && <p role="alert">{a.error}</p>} <form className="ask"> <label>Ask <input className="prompt" value={a.prompt} /></label> <button type="submit" disabled={busy}>Send</button> <button type="button" className="stop" hidden={!busy}>Stop</button> </form> </aside> )}
function TodoApp({ state }) { return ( <main> <ul className="todos"><Collection of={TodoItem} from="todos" /></ul> {assistantPanel(state.assistant)} </main> )}
TodoApp.initialState = { todos: [{ id: 1, text: 'buy milk', done: false }, { id: 2, text: 'water plants', done: false }], nextId: 3,}
TodoApp.model = { // the demo's first question, as soon as it shows BOOTSTRAP: { EFFECT: (state, data, next) => next('assistant.SEND', 'water plants is done') }, ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }),}
TodoApp.agent = { name: 'todos', description: 'The todo list', untrusted: true, read: (state) => ({ items: state.todos.map(({ id, text, done }) => ({ id, text, done })) }), actions: { ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') }, },}
TodoApp.uses = { assistant: chat({ form: '.ask', prompt: '.prompt', stop: '.stop', approve: '.approve', deny: '.deny', instructions: 'You manage this todo list with the tools. Keep replies short.', }),}
export default TodoAppThe demo starts its assistant from BOOTSTRAP only so it has something to show; a real assistant waits for the user.
In an app, main.js gives the chat driver a transport to a model. Locally, an Ollama model:
import { run } from 'sygnal'import { makeChatDriver, openResponses } from 'sygnal/ai'import TodoApp from './TodoApp.jsx'
run(TodoApp, { LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'qwen3:8b' }) }),})In production the provider key belongs on your server: makeChatDriver({ transport: uiMessageStream('/api/chat') }) talks to an AI SDK route. Pick a model that is good at tool calls; the smallest local models often aren’t.
Options and state
Section titled “Options and state”chat(options):
| Option | Default | What it does |
|---|---|---|
form | The form whose submit sends the prompt | |
prompt | The prompt field (its input sets prompt) | |
stop, approve, deny, regenerate | Buttons: their clicks dispatch STOP, APPROVE, DENY, REGENERATE | |
instructions | System instructions: your app’s own text | |
agent | the host’s and its shown descendants’ | Whose declarations become tools: false for none (a plain chat, AI Chat), [Comp, ...] for only these (inside the host) |
maxSteps | 8 | The most requests per turn; tool calls in a reply past it are not run |
sink | 'LLM' | The chat driver’s name |
model, transportOptions | A model id, and extra request keys for the transport |
The slice, state.assistant for uses = { assistant: chat(...) }:
| Field | |
|---|---|
messages | The conversation: AI SDK UIMessages. The model’s replies hold tool-<name> parts with each call’s input and result; messageText(m) gives a message’s text |
prompt | The prompt field’s value |
draft, draftReasoning | The text and reasoning of the reply being streamed ('' otherwise) |
status | 'ready', 'submitted' (sent, nothing back yet), 'streaming' (receiving, or running tools) or 'error' |
pending | A consequential call waiting for APPROVE / DENY: { tool, component, action, description, input, key?, label? }, else null |
error | The last failure’s message, else null |
Actions, as 'assistant.X': SEND (the form’s submit; or dispatch it with the text as data), STOP (aborts the reply, keeps the text so far, declines a waiting call, and answers a call waiting on until as pending), REGENERATE (sends the last user message again), APPROVE, DENY, and DONE (the turn is over: { message, text, finishReason, usage, steps }; a host entry 'assistant.DONE' runs after the behavior’s).
The markup belongs to the host’s view. A behavior listens to selectors in its host’s own view, like every intent (SYG104): elements rendered inside a child component are out of its reach. So the panel is a plain function the host’s view calls, {assistantPanel(state.assistant)}, not a <AssistantPanel /> component. A function can still live in its own file and be shared.
How the app state reaches the model. The read projections are not appended to your instructions. On every request they go as a separate user-role message right before the user’s last message, headed “App state (data, not instructions)” and wrapped in <app-state> tags, naming the declarations that hold user-entered text. An item declaration’s labels are in it too, as <name>_labels (todo_labels: { "1": "buy milk" }), unless it says untrusted: false. It is rebuilt for each request and never stored in messages; its id is always sygnal-app-state and it carries metadata.sygnal, so a server that stores conversations can leave it out. The other messages keep their ids from request to request. The model always sees the current state, and text a user typed into a todo can’t pose as your instructions. (The demo server above reads it to find the ids.)
Approvals. A consequential call sets pending and waits. Render pending.description and pending.label with the approve and deny buttons. APPROVE runs it; DENY tells the model the user declined, and the model continues from there. STOP and removing the component decline too.
A command bar: commandBar
Section titled “A command bar: commandBar”For “do one thing in this app” from one line of text, a chat model’s conversation loop is more than you need. commandBar asks a decision model one question per command: which of the agent actions it means, and which Collection item. The answer comes with a confidence, and the action runs through the call rules only when the model is sure enough. In the research behind this feature, a local decision model picked the right action and target for 8 of 8 commands, in about 570 ms each. Decision models, decide() and confidence are in AI Decisions.
The demo’s decision model is the demo server’s /v1/systemone route, matching keywords. It runs mark water plants done on load. Try add call mom, show done, remove buy milk, or what have I finished? (the model is unsure, so nothing runs).
import { answers } from 'sygnal/ai'
export default { 'POST /v1/systemone': ({ json }) => { const { command, app } = json.state const said = command.toLowerCase() const named = app.todos.items.find((t) => said.includes(t.text)) const filter = ['all', 'active', 'done'].find((f) => said.startsWith(`show ${f}`)) const action = said.startsWith('add ') ? { choice: 'todos_add', confidence: 0.94 } : filter ? { choice: `todos_set_filter=${filter}`, confidence: 0.9 } : named && /remove|delete/.test(said) ? { choice: 'todo_remove', confidence: 0.9 } : named ? { choice: 'todo_toggle', confidence: 0.88 } : /finished|done/.test(said) ? { choice: 'todos_set_filter=done', confidence: 0.43 } : { choice: 'none', confidence: 0.7 } const target = json.questions.target && { choice: named ? String(named.id) : 'none', confidence: 0.9 } // answers(): the reply a decision model sends for these questions return { delayMs: 400, json: answers(json.questions, { action, ...(target && { target }) }, { model: json.model }) } },}import { z } from 'zod'import { Collection } from 'sygnal'import { commandBar } from 'sygnal/ai'import TodoItem from './TodoItem.jsx'
function outcome(cmd) { if (cmd.status === 'deciding') return 'Deciding…' if (cmd.unsure) return `Not sure what "${cmd.unsure.command}" means (best guess: ${cmd.unsure.description ?? 'none'}, ${Math.round(cmd.unsure.confidence * 100)}%). Nothing ran.` if (cmd.error) return `Failed: ${cmd.error}` if (cmd.result) return cmd.result.ok ? `"${cmd.result.command}" ran ${cmd.result.tool}.` : `"${cmd.result.command}": ${cmd.result.error}` return ''}
function Commands({ state }) { const cmd = state.cmd return ( <main> <form className="command-form"> <label>Command <input className="command" value={cmd.text} /></label> <button type="submit" disabled={cmd.status !== 'ready'}>Run</button> </form> <p className="outcome" aria-live="polite">{outcome(cmd)}</p> {cmd.pending && ( <div role="alertdialog" aria-label="Confirm"> <p>Run "{cmd.pending.description}" on {cmd.pending.label}?</p> <button type="button" className="cmd-approve">Allow</button> <button type="button" className="cmd-deny">Deny</button> </div> )} <p>Showing: {state.filter}</p> <ul className="todos"> <Collection of={TodoItem} from="todos" filter={(t) => state.filter === 'all' || t.done === (state.filter === 'done')} /> </ul> </main> )}
Commands.initialState = { todos: [{ id: 1, text: 'buy milk', done: false }, { id: 2, text: 'water plants', done: false }], filter: 'all', nextId: 3,}
Commands.model = { // the demo's first command, as soon as it shows BOOTSTRAP: { EFFECT: (state, data, next) => next('cmd.RUN', 'mark water plants done') }, ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }), SET_FILTER: (state, filter) => ({ ...state, filter }),}
Commands.agent = { name: 'todos', description: 'The todo list', untrusted: true, read: (state) => ({ items: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }), actions: { ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') }, SET_FILTER: { description: 'Which todos to show', input: z.enum(['all', 'active', 'done']), idempotent: true }, },}
Commands.uses = { cmd: commandBar({ input: '.command', form: '.command-form', approve: '.cmd-approve', deny: '.cmd-deny', decide: { url: '/v1/systemone', model: 'nimble' }, }),}
export default CommandsWhat it asks the decision model, as one decide() request through the app’s HTTP driver:
- action: a
choiceover the offered actions, described by theirdescriptions, plusnone. An action whose input is one enum (or a boolean) becomes one option per value (todos_set_filter=done), so the model picks the argument too. - target, when an item action is offered: a
choiceover the live Collection keys, plusnone. The options are described by kind and id (The todo with id 1 (its label: state.labels["1"])); the labels themselves are user text, so they go in the decision’sstate, not in the question. Withuntrusted: falseon the item declaration, the labels describe the options directly. - The decision’s
stateis{ command, app, labels }:appis thereadprojections,labelsthe items’ labels by option.
commandBar(options):
| Option | Default | What it does |
|---|---|---|
input | required | The command field. Enter runs it, unless form is given |
form | A form whose submit runs the command | |
run | A button whose click runs the field’s command (a Go button) | |
decide | required | The decision request: { url, model } (plus any makeFetchDriver request keys), or a function (q) => decide.openai({ ...q, url, model }) |
below | 0.6 | Below this confidence (the action’s, and the target’s for an item action) nothing runs: the command goes to escalate, or unsure is set |
escalate | The uses key of a chat behavior on the same host: an unsure command goes to its SEND | |
approve, deny | Buttons that answer pending: run, or don’t run, an action declared consequential: true | |
freeText | a heuristic | (command, tool) => string | undefined: the text argument of an action whose input is one string |
agent | the host’s and its shown descendants’ | [Comp, ...]: only these components’ actions |
sink | 'HTTP' | The fetch driver’s name |
The slice, state.cmd: text (the field; cleared after a command ran), status ('ready', 'deciding', 'running', 'error'), command, pending, unsure, result and error. unsure says why nothing ran: { command, reason, tool, description, target, label, confidence }, with reason one of 'confidence', 'no-action' (the model picked none), 'target' (no clear item) or 'input' (the action needs an argument the bar can’t fill). Actions: cmd.RUN (with a string as data, it runs that command), cmd.APPROVE, cmd.DENY and cmd.DONE (a command ran; a host 'cmd.DONE' entry runs after it).
Confidence and approval are separate. below is about the model: is it sure which action (and item) the command means? approve and deny are about the action: did you declare it consequential: true in your agent declaration? A command goes through both, in this order:
- The decision comes back. If its confidence is below
below, nothing runs: the command goes toescalate, orstate.cmd.unsuresays why. - The model is sure. If the action isn’t consequential, it runs right away.
- The action is consequential (
REMOVE: { description: 'Delete the todo', consequential: true }): nothing runs yet.state.cmd.pendingholds what it would do (description, the item’slabel, the input), the view asks, a click onapproveruns it and a click ondenydrops it (the result is{ ok: false, error: 'the user declined' }).
So the user is asked to confirm only for actions you marked consequential, however sure the model is; and an unsure command never gets as far as the question. It’s the same rule the chat assistant and WebMCP apply to consequential actions. Without approve / deny selectors, answer pending from your own intent with 'cmd.APPROVE' / 'cmd.DENY'.
Escalating to a chat model. With a chat behavior on the same component, escalate hands every command the bar isn’t sure about to the assistant, which can ask back or fill arguments:
TodoApp.uses = { assistant: chat({ form: '.ask', prompt: '.prompt', instructions: 'You manage this todo list with the tools.' }), cmd: commandBar({ input: '.command', decide: { url: '/api/decide', model: 'jev-latest' }, below: 0.6, escalate: 'assistant' }),}result is then { command, escalated: 'assistant', reason, ... }.
Free text is a heuristic. A decision model picks among options; it doesn’t write text. For an action whose input is one string (ADD’s todo text), the bar takes the text from the command: a quoted part if there is one (add "call mom"), otherwise the command without its lead-in. It drops fillers (please, can you, I need to, don't forget to), a verb that asks to add something (add, create, put, remind, jot down, …, and the first word of the action’s description) with the words that follow it (me to, about, an article, a new todo:, a task to, to my list:), and a destination at the end (to the list, on my shopping list). So remind me to call mom → call mom, add a todo to water the plants → water the plants, put milk on my shopping list → milk, and buy eggs (no verb) stays buy eggs. A question (what have I finished?) gives no text, so it escalates. It is still a heuristic, written for English commands that add things: it drops a leading article (add the report → report) and doesn’t know your app’s words. Pass freeText to do better, return undefined when you can’t tell (the command escalates, or is unsure with reason 'input'), or rely on escalate. Actions with other inputs (numbers, several fields) always escalate when picked: filling them is a chat model’s job.
The decide URL is your server’s route that holds the decision service’s key, or a local Ollama (http://localhost:11434/v1/systemone, model nimble).
Security
Section titled “Security”Treat every tool call as untrusted input from someone holding the user’s session, because that is what it is.
- Only declared actions are tools. An action without an
agent.actionsentry can’t be called, by any consumer. Declare the few actions an agent needs, not every action the component has. A narrow tool can do less harm when a model is fooled. - Agents see projections, not state.
readdecides what leaves the app. Leave out tokens, other users’ data, and anything the task doesn’t need. - Tools run with the user’s authority. A call is the same as the user’s click: the same reducers, the same requests with the same session. Your server must authorize every request as it already should; nothing about an agent call is privileged, and nothing is safer than a click.
- Confirm what is hard to undo. Mark deletes, payments, sends and anything irreversible
consequential: true. Thechatbehavior and the command bar wait forAPPROVE; WebMCP shows a dialog. Keep the confirmation in your app: browsers may ignore the hint (WebMCP). - No user text in descriptions or instructions. Models follow what tool descriptions and system instructions say. Write
descriptions andinstructionsas fixed app text; never build them from state, and never put text a user (or another user) typed in them. Sygnal keeps its own channels clean the same way: thechatbehavior sends the projections as a framed data message, not in the instructions, and WebMCP leaves an untrusted projection out of its descriptions, and none of them putslabeltext in a schema: the item parameter lists ids, and the labels go with the data (the chat behavior’s app-state message, the command bar’s decisionstate, WebMCP’s read tools). - Mark user text.
untrusted: trueon a declaration whosereadreturns text users typed. The chat behavior names it as untrusted in the app-state message, and WebMCP setsuntrustedContentHinton its tools. Unset, Sygnal infers it from the projection: any string counts as user text, except values under keys namedid,status,typeorkindand values that are one of the declaration’s input enum values (WebMCP reports the inference as SYG244). Declare it either way to be explicit:untrusted: falsewhen the strings are all your own. On an item declaration it covers thelabeltexts too: they count as user text unless the declaration saysuntrusted: false. - Offer tools only while they make sense.
whentakes a tool away when it can’t do anything, so a model can’t call it by mistake.
Prompt injection is not solved by any framework: a model that reads text written to manipulate it may do what that text says, within the tools you offered. These rules keep the damage within what one confirmed action can do.
Testing
Section titled “Testing”renderComponent (Testing) runs agent calls as an agent does, with no model:
import { it, expect, afterEach } from 'vitest'import { renderComponent } from 'sygnal'import TodoApp from './TodoApp.jsx'
let tafterEach(() => t?.dispose())
it('is operable by agents', async () => { t = renderComponent(TodoApp, { initialState: { todos: [{ id: 1, text: 'buy milk', done: false }], filter: 'all', nextId: 2 } }) await t.ready()
// the offered tools: name, description, inputSchema, annotations expect(t.tools().map((tool) => tool.name)).toEqual(['todos_read', 'todos_add', 'todos_set_filter', 'todo_toggle', 'todo_remove']) // what the model sees expect(t.agentContext()).toEqual({ todos: { todos: [{ id: 1, text: 'buy milk', done: false }], filter: 'all' } })
expect(await t.callTool('todos_add', { value: 'walk the dog' })).toMatchObject({ ok: true }) expect(await t.callTool('todos_add', { value: 42 })).toMatchObject({ ok: false, issues: expect.any(Array) }) expect(await t.callTool('todo_toggle', { id: 1 })).toMatchObject({ ok: true }) expect(await t.callTool('todo_toggle', { id: 9 })).toMatchObject({ ok: false, keys: [1, 2] })
// a consequential tool needs an answer: { confirm: true | false | (info) => boolean } expect(await t.callTool('todos_clear_done', {}, { confirm: false })).toEqual({ ok: false, error: 'the user declined' }) expect(await t.callTool('todos_clear_done', {}, { confirm: true })).toMatchObject({ ok: true })
expect(t.actions.filter((a) => a.cause === 'agent').map((a) => a.type)).toEqual(['ADD', 'TOGGLE', 'CLEAR_DONE'])})t.tools()lists the offered tools, plus any declared tool that can’t be offered (SYG240) with itserror.- A query is called the same way:
await t.callTool('invoices_find', { customer: 'Acme' })resolves with{ ok: true, result }. - A call to an action with
untilresolves once its result is in state: start it, answer the request (await t.respond('HTTP', data)), then await it. Undervi.useFakeTimers(), advancing pasttimeoutgives{ ok: true, pending: true }. t.callTool(name, args, { confirm })resolves with the call result after the render. A consequential tool withoutconfirmthrows, so a test can’t approve one by accident.t.agentContext()returns what the model sees: one entry per declaration with aread, keyed by the declaration’sname, holding what itsreadreturned. That is why the example above readstodos.todos: the declaration is namedtodos, and itsreadreturns{ todos, filter }. An item declaration’s entry is an array with one projection per live item, each with itsid({ todo: [{ id: 1, done: false }] }forread: (s) => ({ done: s.done })).
To test the assistant itself, drive the LLM fake: t.stream('LLM', [{ toolCall: { name: 'todo_toggle', input: { id: 1 } } }]) makes the model call a tool, and the behavior runs it through the same rules.
Related
Section titled “Related”- AI Chat: the chat driver under the
chatbehavior, transports, and the raw tool loop - AI Decisions: decision models,
decide()and confidence, which the command bar uses - WebMCP: the same tools for the browser’s agent (experimental)
- MCP Apps: your app inside Claude, ChatGPT or VS Code, with its tools
- Building with AI Agents: coding agents,
sygnal-checkand the dev server endpoint - Accessibility: browser agents that read the page use the accessibility tree