Skip to content

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:

Everything on this page is imported from sygnal/ai, except abort (from sygnal). An app that doesn’t use it pays nothing.

TodoApp.jsx
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.

KeyWhat it does
nameThe 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
descriptionWhat 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
untrustedread returns text users typed (see Security). Unset, Sygnal infers it from the strings in the projection
actionsThe only actions agents may run, by action name
queriesRead-only tools that take an input: data too large for read, or answers computed from the state (Queries)

Each entry of actions:

KeyWhat it does
descriptionWhat the action does, in the app’s own words (at most 500 characters for WebMCP)
inputThe schema of the action’s data. Without one the action takes no arguments
consequentialAsk the user before running it
when(state)The tool is offered only while this is true
idempotentA call that changes nothing is a success ({ ok: true, unchanged: true }), not an error
untilThe call answers once the action’s result is in state: a resource name, or a function of the state (Waiting for a result)
timeoutWith until: how long to wait, in ms (default 10,000)

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.

Tools. One per listed action and query, plus <name>_read when the declaration has a read. The todo app above offers:

ToolInputHints
todos_readnoneread-only
todos_add{ value: string }
todos_set_filter{ value: 'all' | 'active' | 'done' }
todos_clear_donenone; only while a todo is doneconsequential
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.

Every call, from any kind of agent, goes through the same steps:

  1. Validation. The arguments are checked against input before 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.
  2. Availability. A tool whose when is false now gets { ok: false, error: 'todos_clear_done is not available now' }.
  3. Confirmation. A consequential action waits for the user (each consumer asks in its own way). A declined call is { ok: false, error: 'the user declined' }. The target and when are checked again after the answer.
  4. Dispatch. The action runs with cause: 'agent', through the same queue, reducers and render as a click. It shows in t.actions, the DevTools action log and the dev MCP endpoint’s recent_actions with that cause.
  5. The result, after the render: { ok: true, state }, where state is the nearest read projection (the component’s, or for an item, its own or its list’s). An action with until answers 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.

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:

Shop.jsx
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 a keepPrevious request or a refetch, which keep the status and set refreshing, are waited for too. A name the component’s resources doesn’t have is SYG248: the call answers at once.
  • until: (state) => boolean is for a result a reply action writes (a driver request with an ok action). 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 throwing until ends 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 the chat behavior’s next request shows it.
  • Calls run one at a time, so a waiting call delays the model’s next one. The chat behavior ends the wait when the user presses Stop or sends a new message (the call answers pending), and agentTools() has cancel() 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.

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.

Invoices.jsx
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) },
},
}
KeyWhat it does
descriptionWhat the query returns, for the model
inputThe 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 what run returned. Nothing is dispatched, nothing is confirmed, and nothing renders: a query can’t change the app.
  • It must be synchronous. A run that returns a promise (an async function, a fetch) fails the call and is SYG246, as is a query without run. Data that has to be loaded belongs to an action: its model entry asks for it (a resources entry or a driver request), and until makes 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 (find next to FIND) 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: true on the declaration: WebMCP then marks the queries with untrustedContentHint too (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.

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, or item when the action’s input has its own id field.
  • label(state) gives each key a label, so a model can map “the milk one” to 1. 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 the chat behavior’s app state as todo_labels ({ "1": "buy milk", "2": "water plants" }), in the command bar’s decision state.labels. When the labels are your app’s own words, untrusted: false on 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.

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).

Demo server
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".'))
},
}
TodoApp.jsx
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 text
const 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 view
function 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 TodoApp

The 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.

chat(options):

OptionDefaultWhat it does
formThe form whose submit sends the prompt
promptThe prompt field (its input sets prompt)
stop, approve, deny, regenerateButtons: their clicks dispatch STOP, APPROVE, DENY, REGENERATE
instructionsSystem instructions: your app’s own text
agentthe 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)
maxSteps8The most requests per turn; tool calls in a reply past it are not run
sink'LLM'The chat driver’s name
model, transportOptionsA model id, and extra request keys for the transport

The slice, state.assistant for uses = { assistant: chat(...) }:

Field
messagesThe 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
promptThe prompt field’s value
draft, draftReasoningThe text and reasoning of the reply being streamed ('' otherwise)
status'ready', 'submitted' (sent, nothing back yet), 'streaming' (receiving, or running tools) or 'error'
pendingA consequential call waiting for APPROVE / DENY: { tool, component, action, description, input, key?, label? }, else null
errorThe 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.

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).

Demo server
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 }) }
},
}
Commands.jsx
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 Commands

What it asks the decision model, as one decide() request through the app’s HTTP driver:

  • action: a choice over the offered actions, described by their descriptions, plus none. 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 choice over the live Collection keys, plus none. 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’s state, not in the question. With untrusted: false on the item declaration, the labels describe the options directly.
  • The decision’s state is { command, app, labels }: app is the read projections, labels the items’ labels by option.

commandBar(options):

OptionDefaultWhat it does
inputrequiredThe command field. Enter runs it, unless form is given
formA form whose submit runs the command
runA button whose click runs the field’s command (a Go button)
deciderequiredThe decision request: { url, model } (plus any makeFetchDriver request keys), or a function (q) => decide.openai({ ...q, url, model })
below0.6Below this confidence (the action’s, and the target’s for an item action) nothing runs: the command goes to escalate, or unsure is set
escalateThe uses key of a chat behavior on the same host: an unsure command goes to its SEND
approve, denyButtons that answer pending: run, or don’t run, an action declared consequential: true
freeTexta heuristic(command, tool) => string | undefined: the text argument of an action whose input is one string
agentthe 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:

  1. The decision comes back. If its confidence is below below, nothing runs: the command goes to escalate, or state.cmd.unsure says why.
  2. The model is sure. If the action isn’t consequential, it runs right away.
  3. The action is consequential (REMOVE: { description: 'Delete the todo', consequential: true }): nothing runs yet. state.cmd.pending holds what it would do (description, the item’s label, the input), the view asks, a click on approve runs it and a click on deny drops 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).

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.actions entry 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. read decides 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. The chat behavior and the command bar wait for APPROVE; 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 and instructions as 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: the chat behavior 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 puts label text 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 decision state, WebMCP’s read tools).
  • Mark user text. untrusted: true on a declaration whose read returns text users typed. The chat behavior names it as untrusted in the app-state message, and WebMCP sets untrustedContentHint on its tools. Unset, Sygnal infers it from the projection: any string counts as user text, except values under keys named id, status, type or kind and 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: false when the strings are all your own. On an item declaration it covers the label texts too: they count as user text unless the declaration says untrusted: false.
  • Offer tools only while they make sense. when takes 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.

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 t
afterEach(() => 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 its error.
  • 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 until resolves once its result is in state: start it, answer the request (await t.respond('HTTP', data)), then await it. Under vi.useFakeTimers(), advancing past timeout gives { ok: true, pending: true }.
  • t.callTool(name, args, { confirm }) resolves with the call result after the render. A consequential tool without confirm throws, so a test can’t approve one by accident.
  • t.agentContext() returns what the model sees: one entry per declaration with a read, keyed by the declaration’s name, holding what its read returned. That is why the example above reads todos.todos: the declaration is named todos, and its read returns { todos, filter }. An item declaration’s entry is an array with one projection per live item, each with its id ({ todo: [{ id: 1, done: false }] } for read: (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.

  • AI Chat: the chat driver under the chat behavior, 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-check and the dev server endpoint
  • Accessibility: browser agents that read the page use the accessibility tree