sygnal 6.1.1 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/astro/server.cjs.js.map +1 -1
  3. package/dist/astro/server.mjs.map +1 -1
  4. package/dist/diagnostics.cjs.js +10 -3
  5. package/dist/diagnostics.cjs.js.map +1 -1
  6. package/dist/diagnostics.esm.js +10 -3
  7. package/dist/diagnostics.esm.js.map +1 -1
  8. package/dist/guide/adapters.md +2 -1
  9. package/dist/guide/agent.md +112 -6
  10. package/dist/guide/ai-chat.md +60 -16
  11. package/dist/guide/ai-decisions.md +1 -1
  12. package/dist/guide/behaviors.md +1 -1
  13. package/dist/guide/error-boundaries.md +2 -1
  14. package/dist/guide/forms.md +1 -1
  15. package/dist/guide/http.md +4 -4
  16. package/dist/guide/mcp-apps.md +4 -2
  17. package/dist/guide/persistence.md +1 -1
  18. package/dist/guide/recipes/ai-form-fill.md +1 -1
  19. package/dist/guide/recipes/ai-summarize.md +1 -1
  20. package/dist/guide/recipes/ai-support-inbox.md +1 -1
  21. package/dist/guide/resources.md +11 -10
  22. package/dist/guide/ui/combobox.md +1 -1
  23. package/dist/guide/undo.md +1 -1
  24. package/dist/guide/web-components.md +1 -0
  25. package/dist/guide/webmcp.md +1 -2
  26. package/dist/index.cjs.js +155 -31
  27. package/dist/index.cjs.js.map +1 -1
  28. package/dist/index.d.ts +42 -6
  29. package/dist/index.esm.js +155 -31
  30. package/dist/index.esm.js.map +1 -1
  31. package/dist/sygnal.min.js +1 -1
  32. package/dist/sygnal.min.js.map +1 -1
  33. package/dist/vike/config/package.json +1 -1
  34. package/llms.txt +2 -2
  35. package/package.json +1 -1
  36. package/src/ai.d.ts +41 -5
  37. package/src/extra/ai/agent/index.ts +111 -26
  38. package/src/extra/ai/chat/behavior.ts +14 -6
  39. package/src/extra/ai/webmcp.ts +5 -3
  40. package/src/extra/diagnostics/codes.ts +10 -3
  41. package/src/index.d.ts +1 -1
@@ -24,7 +24,8 @@ npm install react react-dom
24
24
  The React component here is a small one of our own, written with React's `createElement` (the JSX on these pages is Sygnal's); a component from npm is wrapped the same way:
25
25
 
26
26
  ```js live-file=./Rating.js
27
- // Rating.js: a React component
27
+ // Rating.js
28
+ // a React component
28
29
  import { createElement as h } from 'react'
29
30
 
30
31
  export function Rating({ value, max, onChange }) {
@@ -69,6 +69,7 @@ TodoApp.agent = {
69
69
  | `label(state)` | A [Collection item](#collection-items)'s label for the model |
70
70
  | `untrusted` | `read` returns text users typed (see [Security](#security)). Unset, Sygnal infers it from the strings in the projection |
71
71
  | `actions` | The only actions agents may run, by action name |
72
+ | `queries` | Read-only tools that take an input: data too large for `read`, or answers computed from the state ([Queries](#queries)) |
72
73
 
73
74
  Each entry of `actions`:
74
75
 
@@ -79,6 +80,8 @@ Each entry of `actions`:
79
80
  | `consequential` | Ask the user before running it |
80
81
  | `when(state)` | The tool is offered only while this is true |
81
82
  | `idempotent` | A call that changes nothing is a success (`{ ok: true, unchanged: true }`), not an error |
83
+ | `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](#waiting-for-a-result-until)) |
84
+ | `timeout` | With `until`: how long to wait, in ms (default 10,000) |
82
85
 
83
86
  ### Input schemas
84
87
 
@@ -109,7 +112,7 @@ In TypeScript, on a component typed with its actions ([TypeScript](https://sygna
109
112
 
110
113
  ## What agents see
111
114
 
112
- **Tools.** One per listed action, plus `<name>_read` when the declaration has a `read`. The todo app above offers:
115
+ **Tools.** One per listed action and [query](#queries), plus `<name>_read` when the declaration has a `read`. The todo app above offers:
113
116
 
114
117
  | Tool | Input | Hints |
115
118
  |---|---|---|
@@ -131,7 +134,7 @@ Every call, from any kind of agent, goes through the same steps:
131
134
  2. **Availability.** A tool whose `when` is false now gets `{ ok: false, error: 'todos_clear_done is not available now' }`.
132
135
  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.
133
136
  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.
134
- 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).
137
+ 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`](#waiting-for-a-result-until) answers later, once its result is in state.
135
138
 
136
139
  **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`:
137
140
 
@@ -158,6 +161,107 @@ A refusal is reported even on an `idempotent` action.
158
161
 
159
162
  **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.
160
163
 
164
+ ## Waiting for a result: `until`
165
+
166
+ Some actions only start something: a search sets the query, and a [`resources`](./resources.md) 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:
167
+
168
+ ```jsx
169
+ // Shop.jsx
170
+ import { z } from 'zod'
171
+
172
+ export default function Shop({ state }) {
173
+ const { status, data } = state.search
174
+ return (
175
+ <section>
176
+ {status === 'loading' && <p>Searching…</p>}
177
+ <ul className="results">{(data ?? []).map((p) => <li key={p.id}>{p.name}</li>)}</ul>
178
+ </section>
179
+ )
180
+ }
181
+
182
+ Shop.initialState = { query: '' }
183
+
184
+ Shop.resources = { search: (state) => state.query && { url: '/api/search', query: { q: state.query } } }
185
+
186
+ Shop.model = { SEARCH: (state, query) => ({ ...state, query }) }
187
+
188
+ Shop.agent = {
189
+ name: 'shop',
190
+ description: 'The product search the user sees',
191
+ read: (state) => ({ query: state.query, status: state.search.status, results: (state.search.data ?? []).map(({ id, name }) => ({ id, name })) }),
192
+ actions: {
193
+ SEARCH: { description: 'Search the catalog; the results show on the page', input: z.string().min(1), until: 'search' },
194
+ },
195
+ }
196
+ ```
197
+
198
+ `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.
199
+
200
+ - **`until: 'search'`** names one of the component's resources. The call answers once that resource is neither loading nor refreshing, so a [`keepPrevious`](./resources.md) 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](https://sygnal.js.org/reference/errors/#syg248): the call answers at once.
201
+ - **`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](https://sygnal.js.org/reference/errors/#syg241)).
202
+ - **The test runs after the action's render and after every render that changed something.** A result already in state answers at once.
203
+ - **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.
204
+ - **Calls run one at a time**, so a waiting call delays the model's next one. The [`chat` behavior](#an-in-app-assistant-chat) ends the wait when the user presses Stop or sends a new message (the call answers `pending`), and `agentTools()` has `cancel()` for other consumers.
205
+ - An item that removes itself while its call waits answers `{ ok: true, removed: true, state }`.
206
+
207
+ 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.
208
+
209
+ ## Queries
210
+
211
+ `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.
212
+
213
+ ```jsx
214
+ // Invoices.jsx
215
+ import { z } from 'zod'
216
+
217
+ export default function Invoices({ state }) {
218
+ return <p className="count">{state.invoices.length} invoices</p>
219
+ }
220
+
221
+ Invoices.initialState = { invoices: [] }
222
+
223
+ const Filter = z.object({
224
+ customer: z.string().optional().describe('The customer name'),
225
+ overdue: z.boolean().optional(),
226
+ })
227
+
228
+ Invoices.agent = {
229
+ name: 'invoices',
230
+ description: 'The invoices',
231
+ untrusted: true,
232
+ read: (state) => ({ count: state.invoices.length }), // the summary, on every request
233
+ queries: {
234
+ // invoices_find({ customer?, overdue? })
235
+ find: {
236
+ description: 'The invoices matching a filter, at most 50',
237
+ input: Filter,
238
+ run: (state, { customer, overdue }) => state.invoices
239
+ .filter((i) => (customer === undefined || i.customer === customer) && (overdue === undefined || i.overdue === overdue))
240
+ .slice(0, 50),
241
+ },
242
+ // invoices_total()
243
+ total: { description: 'The sum of all invoices', run: (state) => state.invoices.reduce((sum, i) => sum + i.total, 0) },
244
+ },
245
+ }
246
+ ```
247
+
248
+ | Key | What it does |
249
+ |---|---|
250
+ | `description` | What the query returns, for the model |
251
+ | `input` | The schema of its input, as for an action ([Input schemas](#input-schemas)). Without one the query takes no arguments |
252
+ | `when(state)` | The tool is offered only while this is true |
253
+ | `run(state, input)` | The result: a pure, synchronous function of the component's state now and the validated input |
254
+
255
+ - **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).
256
+ - **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.
257
+ - **It must be synchronous.** A `run` that returns a promise (an `async` function, a `fetch`) fails the call and is [SYG246](https://sygnal.js.org/reference/errors/#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`](./resources.md) entry or a driver request), and [`until`](#waiting-for-a-result-until) makes the call answer with the result.
258
+ - **A throw** fails the call with its message and is [SYG241](https://sygnal.js.org/reference/errors/#syg241).
259
+ - **Actions and queries share the tool names.** A query whose snake-case name is an action's (`find` next to `FIND`) is [SYG247](https://sygnal.js.org/reference/errors/#syg247), and only the action is offered.
260
+ - **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.
261
+ - **Results are data.** When they hold text users typed, set `untrusted: true` on the declaration: WebMCP then marks the queries with `untrustedContentHint` too ([Security](#security)). Keep results small (filter, then cut): WebMCP cuts a result over 1,500 characters of JSON ([SYG242](https://sygnal.js.org/reference/errors/#syg242)).
262
+
263
+ 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`.
264
+
161
265
  ## Collection items
162
266
 
163
267
  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' `id`s), routed to the item with that key.
@@ -259,7 +363,7 @@ export default {
259
363
  }
260
364
  ```
261
365
 
262
- ```jsx live live-height=380
366
+ ```jsx live live-height=380 title="TodoApp.jsx"
263
367
  import { z } from 'zod'
264
368
  import { Collection } from 'sygnal'
265
369
  import { chat, messageText } from 'sygnal/ai'
@@ -361,7 +465,7 @@ In production the provider key belongs on your server: `makeChatDriver({ transpo
361
465
  | `prompt` | | The prompt field (its input sets `prompt`) |
362
466
  | `stop`, `approve`, `deny`, `regenerate` | | Buttons: their clicks dispatch `STOP`, `APPROVE`, `DENY`, `REGENERATE` |
363
467
  | `instructions` | | System instructions: your app's own text |
364
- | `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none, `[Comp, ...]` for only these (inside the host) |
468
+ | `agent` | the host's and its shown descendants' | Whose declarations become tools: `false` for none (a plain chat, [AI Chat](./ai-chat.md#the-same-chat-with-the-chat-behavior)), `[Comp, ...]` for only these (inside the host) |
365
469
  | `maxSteps` | `8` | The most requests per turn; tool calls in a reply past it are not run |
366
470
  | `sink` | `'LLM'` | The chat driver's name |
367
471
  | `model`, `transportOptions` | | A model id, and extra request keys for the transport |
@@ -377,7 +481,7 @@ The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
377
481
  | `pending` | A consequential call waiting for `APPROVE` / `DENY`: `{ tool, component, action, description, input, key?, label? }`, else `null` |
378
482
  | `error` | The last failure's message, else `null` |
379
483
 
380
- 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), `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).
484
+ 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`](#waiting-for-a-result-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).
381
485
 
382
486
  **The markup belongs to the host's view.** A behavior listens to selectors in its host's own view, like every intent ([SYG104](https://sygnal.js.org/reference/errors/#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.
383
487
 
@@ -414,7 +518,7 @@ export default {
414
518
  }
415
519
  ```
416
520
 
417
- ```jsx live live-height=300
521
+ ```jsx live live-height=300 title="Commands.jsx"
418
522
  import { z } from 'zod'
419
523
  import { Collection } from 'sygnal'
420
524
  import { commandBar } from 'sygnal/ai'
@@ -581,6 +685,8 @@ it('is operable by agents', async () => {
581
685
  ```
582
686
 
583
687
  - `t.tools()` lists the offered tools, plus any declared tool that can't be offered (SYG240) with its `error`.
688
+ - A [query](#queries) is called the same way: `await t.callTool('invoices_find', { customer: 'Acme' })` resolves with `{ ok: true, result }`.
689
+ - A call to an action with [`until`](#waiting-for-a-result-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 }`.
584
690
  - `t.callTool(name, args, { confirm })` resolves with the [call result](#the-call-rules) after the render. A consequential tool without `confirm` throws, so a test can't approve one by accident.
585
691
  - `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 })`).
586
692
 
@@ -49,7 +49,7 @@ export default {
49
49
  }
50
50
  ```
51
51
 
52
- ```jsx live live-height=260
52
+ ```jsx live live-height=260 title="Chat.jsx"
53
53
  import { ABORT } from 'sygnal'
54
54
  import { messageText } from 'sygnal/ai'
55
55
 
@@ -71,7 +71,6 @@ function Chat({ state }) {
71
71
  )
72
72
  }
73
73
 
74
- const userSays = (text) => ({ role: 'user', parts: [{ type: 'text', text }] })
75
74
  const ask = (messages) => ({ messages, key: 'reply', delta: 'DELTA', ok: 'DONE', error: 'FAILED' })
76
75
 
77
76
  Chat.initialState = { messages: [], prompt: '', draft: '', status: 'ready', error: null }
@@ -85,16 +84,16 @@ Chat.intent = ({ DOM }) => ({
85
84
  Chat.model = {
86
85
  // the first question, as soon as the demo shows
87
86
  BOOTSTRAP: {
88
- STATE: (state) => ({ ...state, messages: [userSays('How does a reply stream?')], status: 'streaming' }),
89
- LLM: () => ask([userSays('How does a reply stream?')]),
87
+ STATE: (state) => ({ ...state, messages: [{ role: 'user', content: 'How does a reply stream?' }], status: 'streaming' }),
88
+ LLM: () => ask([{ role: 'user', content: 'How does a reply stream?' }]),
90
89
  },
91
90
  TYPE: (state, prompt) => ({ ...state, prompt }),
92
91
  SEND: {
93
92
  STATE: (state) => (state.prompt.trim()
94
- ? { ...state, messages: [...state.messages, userSays(state.prompt)], prompt: '', status: 'streaming', error: null }
93
+ ? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
95
94
  : ABORT),
96
95
  // sinks see the state before the action: the conversation plus the prompt being sent
97
- LLM: (state) => (state.prompt.trim() ? ask([...state.messages, userSays(state.prompt)]) : ABORT),
96
+ LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
98
97
  },
99
98
  STOP: {
100
99
  // keep the text that arrived; nothing arrives after the abort
@@ -114,12 +113,57 @@ Chat.model = {
114
113
 
115
114
  What the component does:
116
115
 
117
- - **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn.
116
+ - **The conversation is state.** `messages` holds the user's messages and the model's replies. Every request sends the whole conversation: models are stateless, so the history goes with each turn. A message you write yourself can be `{ role: 'user', content: text }`: the driver, `messageText()` and every transport accept that short form.
118
117
  - **`SEND` has two sinks.** `STATE` adds the user's message, `LLM` sends the request. Both see the state from before the action ([Model](https://sygnal.js.org/guide/model/#sinks-see-the-state-before-the-action)), so the `LLM` sink builds the message list with the prompt itself.
119
118
  - **`DELTA` shows the reply so far.** Its `text` is all the text received, not just the new piece, so the reducer only replaces `draft`.
120
119
  - **`DONE` gets the finished message** and appends it to the conversation as is.
121
120
  - **`STOP` aborts the request** by its key. Nothing arrives after an abort, not even `FAILED`.
122
121
 
122
+ ### The same chat with the `chat` behavior
123
+
124
+ The component above shows how the driver works. For a conversation in your app, use the `chat` behavior from `sygnal/ai` instead: it holds the conversation, sends the requests, and handles streaming, Stop, failures and Retry. With `agent: false` it sends no tools, so it is a plain chat. Its markup stays in your view, and its options name the elements to listen to.
125
+
126
+ This demo asks the same first question. Press **Stop** while it streams to keep the partial reply.
127
+
128
+ ```jsx live live-height=260
129
+ import { chat, messageText } from 'sygnal/ai'
130
+
131
+ function Chat({ state }) {
132
+ const c = state.chat
133
+ const busy = c.status === 'submitted' || c.status === 'streaming'
134
+ return (
135
+ <section className="chat">
136
+ <ol className="messages" aria-live="polite">
137
+ {c.messages.map((m) => <li key={m.id} className={m.role}><b>{m.role}:</b> {messageText(m)}</li>)}
138
+ {c.draft && <li className="assistant streaming"><b>assistant:</b> {c.draft}</li>}
139
+ </ol>
140
+ {c.error && <p role="alert">{c.error} <button type="button" className="retry">Retry</button></p>}
141
+ <form className="ask">
142
+ <label>Message <input className="prompt" value={c.prompt} /></label>
143
+ <button type="submit" disabled={busy}>Send</button>
144
+ <button type="button" className="stop" hidden={!busy}>Stop</button>
145
+ </form>
146
+ </section>
147
+ )
148
+ }
149
+
150
+ Chat.uses = {
151
+ chat: chat({ agent: false, form: '.ask', prompt: '.prompt', stop: '.stop', regenerate: '.retry' }),
152
+ }
153
+
154
+ Chat.model = {
155
+ // the first question, as soon as the demo shows
156
+ BOOTSTRAP: { EFFECT: (state, data, next) => next('chat.SEND', 'How does a reply stream?') },
157
+ }
158
+ ```
159
+
160
+ - **`state.chat` is the behavior's slice**: `messages`, `prompt`, `draft`, `status` (`'ready'`, `'submitted'`, `'streaming'` or `'error'`) and `error`.
161
+ - **Retry doesn't repeat the question.** `regenerate` sends the conversation again up to the last user message, without a second copy of it, and drops the failed partial reply.
162
+ - **Every message has an `id`**, the user's too, so `key={m.id}` works and a server can store the conversation by message id.
163
+ - **The host can drive it.** `next('chat.SEND', text)` sends a message, and a `'chat.DONE'` entry in the host's model runs when a reply is finished.
164
+
165
+ Use the driver directly for one-off requests, like a summary or [structured output](#structured-output), or when you need your own state shape. The [`chat` behavior](./agent.md#options-and-state) documents its options, state and actions. Without `agent: false` it also runs your components' [agent tools](./agent.md).
166
+
123
167
  ## Requests
124
168
 
125
169
  A request is an object sent to the `LLM` sink. The driver passes it to the transport as is, so a transport's own options can go in it too.
@@ -197,7 +241,7 @@ export default {
197
241
  }
198
242
  ```
199
243
 
200
- ```jsx live live-height=200
244
+ ```jsx live live-height=200 title="QuickAdd.jsx"
201
245
  import { z } from 'zod'
202
246
  import { ABORT } from 'sygnal'
203
247
 
@@ -296,7 +340,7 @@ export default {
296
340
  }
297
341
  ```
298
342
 
299
- ```jsx live live-height=260
343
+ ```jsx live live-height=260 title="Shopping.jsx"
300
344
  import { ABORT } from 'sygnal'
301
345
  import { messageText, withToolResults } from 'sygnal/ai'
302
346
 
@@ -308,7 +352,6 @@ const tools = {
308
352
  }
309
353
 
310
354
  const said = (m) => messageText(m) || m.parts.filter((p) => p.type.startsWith('tool-')).map((p) => `(called ${p.type.slice(5)})`).join(' ')
311
- const userSays = (text) => ({ role: 'user', parts: [{ type: 'text', text }] })
312
355
  const ask = (messages) => ({ messages, tools, key: 'reply', delta: 'DELTA', tool: 'TOOL', ok: 'DONE', error: 'FAILED' })
313
356
 
314
357
  // the reply with each add_item call answered: the output goes into the call's tool-add_item part
@@ -343,9 +386,9 @@ Shopping.model = {
343
386
  TYPE: (state, prompt) => ({ ...state, prompt }),
344
387
  SEND: {
345
388
  STATE: (state) => (state.prompt.trim()
346
- ? { ...state, messages: [...state.messages, userSays(state.prompt)], prompt: '', status: 'streaming', error: null }
389
+ ? { ...state, messages: [...state.messages, { role: 'user', content: state.prompt }], prompt: '', status: 'streaming', error: null }
347
390
  : ABORT),
348
- LLM: (state) => (state.prompt.trim() ? ask([...state.messages, userSays(state.prompt)]) : ABORT),
391
+ LLM: (state) => (state.prompt.trim() ? ask([...state.messages, { role: 'user', content: state.prompt }]) : ABORT),
349
392
  },
350
393
  DELTA: (state, { text }) => ({ ...state, draft: text }),
351
394
  // runs the call as it arrives; the model's input is unchecked, so check it
@@ -464,8 +507,8 @@ The transports refuse the other mistake for you. In a browser, a request with an
464
507
 
465
508
  The component never changes. Only the transport in `main.js` does: Ollama while you develop, your AI SDK route in production.
466
509
 
467
- ```js
468
- // main.js: development, a local model
510
+ ```js title="main.js (development)"
511
+ // a local model, through Ollama
469
512
  import { run } from 'sygnal'
470
513
  import { makeChatDriver, openResponses } from 'sygnal/ai'
471
514
  import Chat from './Chat.jsx'
@@ -475,8 +518,8 @@ run(Chat, {
475
518
  })
476
519
  ```
477
520
 
478
- ```js
479
- // main.js: production, the AI SDK route above
521
+ ```js title="main.js (production)"
522
+ // the AI SDK route above
480
523
  import { run } from 'sygnal'
481
524
  import { makeChatDriver, uiMessageStream } from 'sygnal/ai'
482
525
  import Chat from './Chat.jsx'
@@ -489,6 +532,7 @@ run(Chat, {
489
532
  Or pick one at build time, so the development transport isn't in the production bundle:
490
533
 
491
534
  ```js
535
+ // main.js
492
536
  import { run } from 'sygnal'
493
537
  import { makeChatDriver, openResponses, uiMessageStream } from 'sygnal/ai'
494
538
  import Chat from './Chat.jsx'
@@ -157,7 +157,7 @@ export default {
157
157
  }
158
158
  ```
159
159
 
160
- ```jsx live live-height=220
160
+ ```jsx live live-height=220 title="Ticket.jsx"
161
161
  import { ABORT } from 'sygnal'
162
162
  import { decide, choice, noul } from 'sygnal/ai'
163
163
 
@@ -35,7 +35,7 @@ export default {
35
35
  }
36
36
  ```
37
37
 
38
- ```jsx live
38
+ ```jsx live title="TaskList.jsx"
39
39
  import { pager } from 'sygnal'
40
40
 
41
41
  export function TaskList({ state }) {
@@ -123,7 +123,8 @@ Compared with React, which unmounts the whole tree when an error isn't caught by
123
123
  In a [Vike](https://sygnal.js.org/integration/vike/) app, the hook is the `sygnalOnError` config: define it in `+sygnalOnError.js` (or in `+config.js`). It is passed to `renderToString` on the server and to `run()` in the browser. (Vike's own `onError` is a different hook: server-only, called with `(error, pageContext)` for any error during rendering.)
124
124
 
125
125
  ```js
126
- // pages/+sygnalOnError.js: used by renderToString on the server and by run() in the browser
126
+ // pages/+sygnalOnError.js
127
+ // used by renderToString on the server and by run() in the browser
127
128
  export default function sygnalOnError(error, { componentName, action, phase }) {
128
129
  console.error('[' + phase + ']', componentName, action, error)
129
130
  }
@@ -21,7 +21,7 @@ export default {
21
21
 
22
22
  Validation, a list of rows the user adds and removes, the submit to the server, the pending button, and the server's errors:
23
23
 
24
- ```jsx live
24
+ ```jsx live title="Signup.jsx"
25
25
  import { form } from 'sygnal'
26
26
  import { z } from 'zod'
27
27
 
@@ -24,7 +24,7 @@ export default {
24
24
  }
25
25
  ```
26
26
 
27
- ```jsx live
27
+ ```jsx live title="Quote.jsx"
28
28
  import { ABORT } from 'sygnal'
29
29
 
30
30
  function Quote({ state }) {
@@ -84,7 +84,7 @@ export default {
84
84
  }
85
85
  ```
86
86
 
87
- ```jsx live
87
+ ```jsx live title="Search.jsx"
88
88
  import { ABORT, debounce } from 'sygnal'
89
89
 
90
90
  function Search({ state }) {
@@ -219,7 +219,7 @@ export default {
219
219
  }
220
220
  ```
221
221
 
222
- ```jsx live
222
+ ```jsx live title="Todos.jsx"
223
223
  function Todos({ state }) {
224
224
  return (
225
225
  <div>
@@ -261,7 +261,7 @@ export default {
261
261
  }
262
262
  ```
263
263
 
264
- ```jsx live
264
+ ```jsx live title="Profile.jsx"
265
265
  import { ABORT } from 'sygnal'
266
266
 
267
267
  function Profile({ state }) {
@@ -8,7 +8,8 @@ In Sygnal that bridge is a driver. The view is an ordinary component.
8
8
  ## The view
9
9
 
10
10
  ```jsx
11
- // App.jsx: the view of the get_forecast tool
11
+ // App.jsx
12
+ // the view of the get_forecast tool
12
13
  function App({ state }) {
13
14
  return (
14
15
  <main>
@@ -168,7 +169,8 @@ run(App, { MCP: makeMcpAppDriver({ tools: agentTools }) })
168
169
  ```
169
170
 
170
171
  ```jsx
171
- // App.jsx: what the model may do in the view
172
+ // App.jsx
173
+ // what the model may do in the view
172
174
  import { z } from 'zod'
173
175
 
174
176
  App.model = {
@@ -214,7 +214,7 @@ The object you pass is used as the storage, not copied: writes land in it, and t
214
214
 
215
215
  With `format: 'plain'` the entries are the stored keys themselves, both in the `storage` option and from `t.storage(key)`:
216
216
 
217
- ```jsx
217
+ ```jsx title="Note.test.jsx"
218
218
  it('saves the draft as { title, body }', async () => {
219
219
  const t = renderComponent(Note, { storage: { 'note-draft': { title: 'Groceries', body: '' } } })
220
220
  await t.ready()
@@ -34,7 +34,7 @@ export default {
34
34
  }
35
35
  ```
36
36
 
37
- ```jsx live live-height=420
37
+ ```jsx live live-height=420 title="NewContact.jsx"
38
38
  import { z } from 'zod'
39
39
  import { ABORT, form } from 'sygnal'
40
40
 
@@ -68,7 +68,7 @@ export default {
68
68
  }
69
69
  ```
70
70
 
71
- ```jsx live live-height=260
71
+ ```jsx live live-height=260 title="Article.jsx"
72
72
  import { ABORT } from 'sygnal'
73
73
 
74
74
  const TEXT = 'The octopus has three hearts: two pump blood through the gills, and one through the rest of the body. Its blood is blue, because it carries oxygen with copper instead of iron. Two thirds of its neurons are in its arms, so each arm can taste, touch and react without waiting for the brain. Most octopuses live only a year or two.'
@@ -66,7 +66,7 @@ export default {
66
66
  }
67
67
  ```
68
68
 
69
- ```jsx live live-height=280
69
+ ```jsx live live-height=280 title="Inbox.jsx"
70
70
  import { z } from 'zod'
71
71
  import { ABORT, Collection } from 'sygnal'
72
72
  import { decide, choice, noul } from 'sygnal/ai'
@@ -17,7 +17,7 @@ export default {
17
17
  }
18
18
  ```
19
19
 
20
- ```jsx live
20
+ ```jsx live title="Quote.jsx"
21
21
  function Quote({ state }) {
22
22
  const { status, data, refreshing } = state.quote
23
23
  return (
@@ -126,7 +126,8 @@ export const router = makeRouter({
126
126
  ```
127
127
 
128
128
  ```jsx
129
- // TaskPage.jsx: the page reads the same request
129
+ // TaskPage.jsx
130
+ // the page reads the same request
130
131
  TaskPage.resources = { task: (state) => routeData.task(state.route)[0] }
131
132
 
132
133
  // App.jsx: { prefetch } on the ROUTER sink, e.g. on hover, warms the route's data
@@ -151,8 +152,7 @@ A seeded entry keeps its `updatedAt`, so it stays fresh for `staleTime` after th
151
152
 
152
153
  A `+data` hook seeds a cache and puts its snapshot on `pageContext.queryCache`. The Sygnal Vike extension renders the page's resources from it, passes it to the client, and hydrates the cache of every fetch driver in the `drivers` config before the page runs. On client-side navigation it does the same with the next page's snapshot.
153
154
 
154
- ```javascript
155
- // pages/quote/@id/+data.js
155
+ ```javascript title="pages/quote/@id/+data.js"
156
156
  import { queryCache } from 'sygnal'
157
157
 
158
158
  export async function data(pageContext) {
@@ -166,14 +166,14 @@ export async function data(pageContext) {
166
166
  ```
167
167
 
168
168
  ```javascript
169
- // pages/+drivers.js: client-only; one cache for the session
169
+ // pages/+drivers.js
170
+ // client-only; one cache for the session
170
171
  import { makeFetchDriver, queryCache } from 'sygnal'
171
172
 
172
173
  export default { HTTP: makeFetchDriver({ cache: queryCache({ staleTime: 30000 }) }) }
173
174
  ```
174
175
 
175
- ```jsx
176
- // pages/quote/@id/+Page.jsx
176
+ ```jsx title="pages/quote/@id/+Page.jsx"
177
177
  function Page({ state }) {
178
178
  const { status, data } = state.quote
179
179
  return <p className="quote">{status === 'success' ? data.text : 'Loading…'}</p>
@@ -294,7 +294,7 @@ export default {
294
294
  .rows.stale { opacity: 0.5; }
295
295
  ```
296
296
 
297
- ```jsx live
297
+ ```jsx live title="Pages.jsx"
298
298
  function Pages({ state }) {
299
299
  const { data, refreshing } = state.list
300
300
  return (
@@ -373,7 +373,7 @@ export default {
373
373
  }
374
374
  ```
375
375
 
376
- ```jsx live
376
+ ```jsx live title="App.jsx"
377
377
  const statusOf = (r) => (r?.status === 'loading' ? 'Loading…' : r?.refreshing ? 'Updating…' : '')
378
378
 
379
379
  function App({ state }) {
@@ -426,7 +426,8 @@ App.model = {
426
426
  ```
427
427
 
428
428
  ```javascript
429
- // main.js; the tests use renderComponent(App, { http: { cache: queryCache() } })
429
+ // main.js
430
+ // the tests use renderComponent(App, { http: { cache: queryCache() } })
430
431
  run(App, { HTTP: makeFetchDriver({ cache: queryCache() }) })
431
432
  ```
432
433
 
@@ -49,7 +49,7 @@ export default {
49
49
  }
50
50
  ```
51
51
 
52
- ```jsx live live-height=240
52
+ ```jsx live live-height=240 title="Search.jsx"
53
53
  import { Combobox } from 'sygnal/ui/combobox'
54
54
 
55
55
  function Search({ state }) {
@@ -112,7 +112,7 @@ export default {
112
112
  }
113
113
  ```
114
114
 
115
- ```jsx live
115
+ ```jsx live title="Editor.jsx"
116
116
  import { undoable } from 'sygnal'
117
117
 
118
118
  export function Editor({ state }) {
@@ -152,6 +152,7 @@ defineElement('score-card', ScoreCard, {
152
152
  ```
153
153
 
154
154
  ```html
155
+ <!-- index.html -->
155
156
  <score-card title="Pasta" score="3"></score-card>
156
157
  <script>
157
158
  document.querySelector('score-card').addEventListener('score-change', (e) => console.log(e.detail.score))
@@ -43,8 +43,7 @@ Chrome 153 has WebMCP behind a flag: enable **WebMCP for testing** at `chrome://
43
43
 
44
44
  **Other browsers: the polyfill.** [`@mcp-b/webmcp-polyfill`](https://www.npmjs.com/package/@mcp-b/webmcp-polyfill) installs a `document.modelContext` in any browser, so an agent extension that speaks it can call your tools. Load it before `experimentalExposeWebMcp`, in development only:
45
45
 
46
- ```js
47
- // main.js (development)
46
+ ```js title="main.js (development)"
48
47
  import { run } from 'sygnal'
49
48
  import { experimentalExposeWebMcp } from 'sygnal/ai'
50
49
  import TodoApp from './TodoApp.jsx'