sygnal 5.4.0 → 6.1.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 (360) hide show
  1. package/CHANGELOG.md +716 -0
  2. package/README.md +24 -9
  3. package/dist/ai.cjs.js +115 -0
  4. package/dist/ai.cjs.js.map +1 -0
  5. package/dist/ai.esm.js +2 -0
  6. package/dist/ai.esm.js.map +1 -0
  7. package/dist/astro/client.cjs.js +16 -9
  8. package/dist/astro/client.cjs.js.map +1 -1
  9. package/dist/astro/client.mjs +16 -9
  10. package/dist/astro/client.mjs.map +1 -1
  11. package/dist/astro/index.cjs.js +958 -92
  12. package/dist/astro/index.cjs.js.map +1 -1
  13. package/dist/astro/index.mjs +958 -93
  14. package/dist/astro/index.mjs.map +1 -1
  15. package/dist/astro/server.cjs.js +3396 -165
  16. package/dist/astro/server.cjs.js.map +1 -1
  17. package/dist/astro/server.mjs +3396 -165
  18. package/dist/astro/server.mjs.map +1 -1
  19. package/dist/devtools.cjs.js +1840 -0
  20. package/dist/devtools.cjs.js.map +1 -0
  21. package/dist/devtools.esm.js +1827 -0
  22. package/dist/devtools.esm.js.map +1 -0
  23. package/dist/diagnostics.cjs.js +3190 -296
  24. package/dist/diagnostics.cjs.js.map +1 -1
  25. package/dist/diagnostics.esm.js +3190 -296
  26. package/dist/diagnostics.esm.js.map +1 -1
  27. package/dist/element.cjs.js +230 -0
  28. package/dist/element.cjs.js.map +1 -0
  29. package/dist/element.esm.js +228 -0
  30. package/dist/element.esm.js.map +1 -0
  31. package/dist/guide/accessibility.md +310 -0
  32. package/dist/guide/adapters.md +221 -0
  33. package/dist/guide/agent.md +588 -0
  34. package/dist/guide/ai-chat.md +693 -0
  35. package/dist/guide/ai-decisions.md +330 -0
  36. package/dist/guide/behaviors.md +312 -0
  37. package/dist/guide/browser-sources.md +203 -0
  38. package/dist/guide/drag-and-drop.md +342 -0
  39. package/dist/guide/element-commands.md +306 -0
  40. package/dist/guide/error-boundaries.md +169 -0
  41. package/dist/guide/forms-reference.md +409 -0
  42. package/dist/guide/forms.md +251 -0
  43. package/dist/guide/http.md +301 -0
  44. package/dist/guide/inputs.md +173 -0
  45. package/dist/guide/mcp-apps.md +272 -0
  46. package/dist/guide/persistence.md +245 -0
  47. package/dist/guide/recipes/ai-form-fill.md +163 -0
  48. package/dist/guide/recipes/ai-summarize.md +155 -0
  49. package/dist/guide/recipes/ai-support-inbox.md +190 -0
  50. package/dist/guide/recipes/carousel.md +155 -0
  51. package/dist/guide/recipes/charts.md +177 -0
  52. package/dist/guide/recipes/code-editor.md +135 -0
  53. package/dist/guide/recipes/data-grid.md +155 -0
  54. package/dist/guide/recipes/data-table.md +196 -0
  55. package/dist/guide/recipes/i18n.md +222 -0
  56. package/dist/guide/recipes/icons.md +115 -0
  57. package/dist/guide/recipes/overview.md +43 -0
  58. package/dist/guide/recipes/rich-text.md +154 -0
  59. package/dist/guide/resources.md +471 -0
  60. package/dist/guide/ssr.md +255 -0
  61. package/dist/guide/timers.md +178 -0
  62. package/dist/guide/ui/accordion.md +93 -0
  63. package/dist/guide/ui/combobox.md +144 -0
  64. package/dist/guide/ui/dialog.md +124 -0
  65. package/dist/guide/ui/disclosure.md +60 -0
  66. package/dist/guide/ui/menu.md +125 -0
  67. package/dist/guide/ui/overview.md +166 -0
  68. package/dist/guide/ui/popover.md +101 -0
  69. package/dist/guide/ui/select.md +114 -0
  70. package/dist/guide/ui/tabs.md +161 -0
  71. package/dist/guide/ui/toaster.md +152 -0
  72. package/dist/guide/ui/tooltip.md +103 -0
  73. package/dist/guide/undo.md +178 -0
  74. package/dist/guide/virtual-collections.md +183 -0
  75. package/dist/guide/web-components.md +179 -0
  76. package/dist/guide/webmcp.md +226 -0
  77. package/dist/guide/widgets.md +217 -0
  78. package/dist/index.cjs.js +19288 -7408
  79. package/dist/index.cjs.js.map +1 -1
  80. package/dist/index.d.ts +4466 -552
  81. package/dist/index.esm.js +19274 -7461
  82. package/dist/index.esm.js.map +1 -1
  83. package/dist/jsx-dev-runtime.cjs.js +26 -334
  84. package/dist/jsx-dev-runtime.cjs.js.map +1 -1
  85. package/dist/jsx-dev-runtime.esm.js +19 -327
  86. package/dist/jsx-dev-runtime.esm.js.map +1 -1
  87. package/dist/jsx-runtime.cjs.js +26 -334
  88. package/dist/jsx-runtime.cjs.js.map +1 -1
  89. package/dist/jsx-runtime.esm.js +19 -327
  90. package/dist/jsx-runtime.esm.js.map +1 -1
  91. package/dist/jsx.cjs.js +10 -314
  92. package/dist/jsx.cjs.js.map +1 -1
  93. package/dist/jsx.esm.js +8 -315
  94. package/dist/jsx.esm.js.map +1 -1
  95. package/dist/react.cjs.js +117 -0
  96. package/dist/react.cjs.js.map +1 -0
  97. package/dist/react.esm.js +115 -0
  98. package/dist/react.esm.js.map +1 -0
  99. package/dist/shims/globalthis.cjs +20 -0
  100. package/dist/sygnal.min.js +1 -1
  101. package/dist/sygnal.min.js.map +1 -1
  102. package/dist/ui-combobox.cjs.js +169 -0
  103. package/dist/ui-combobox.cjs.js.map +1 -0
  104. package/dist/ui-combobox.esm.js +148 -0
  105. package/dist/ui-combobox.esm.js.map +1 -0
  106. package/dist/ui-menu.cjs.js +87 -0
  107. package/dist/ui-menu.cjs.js.map +1 -0
  108. package/dist/ui-menu.esm.js +66 -0
  109. package/dist/ui-menu.esm.js.map +1 -0
  110. package/dist/ui-select.cjs.js +104 -0
  111. package/dist/ui-select.cjs.js.map +1 -0
  112. package/dist/ui-select.esm.js +83 -0
  113. package/dist/ui-select.esm.js.map +1 -0
  114. package/dist/ui.cjs.js +738 -0
  115. package/dist/ui.cjs.js.map +1 -0
  116. package/dist/ui.esm.js +727 -0
  117. package/dist/ui.esm.js.map +1 -0
  118. package/dist/vike/ClientOnly.cjs.js +4 -14
  119. package/dist/vike/ClientOnly.cjs.js.map +1 -1
  120. package/dist/vike/ClientOnly.mjs +4 -14
  121. package/dist/vike/ClientOnly.mjs.map +1 -1
  122. package/dist/vike/{+config.js → config/+config.js} +9 -1
  123. package/dist/vike/config/+config.js.map +1 -0
  124. package/dist/vike/config/package.json +6 -0
  125. package/dist/vike/onRenderClient.cjs.js +165 -95
  126. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  127. package/dist/vike/onRenderClient.mjs +165 -95
  128. package/dist/vike/onRenderClient.mjs.map +1 -1
  129. package/dist/vike/onRenderHtml.cjs.js +59 -24
  130. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  131. package/dist/vike/onRenderHtml.mjs +60 -25
  132. package/dist/vike/onRenderHtml.mjs.map +1 -1
  133. package/dist/vite/plugin.cjs.js +919 -89
  134. package/dist/vite/plugin.cjs.js.map +1 -1
  135. package/dist/vite/plugin.mjs +919 -90
  136. package/dist/vite/plugin.mjs.map +1 -1
  137. package/dist/zag.cjs.js +408 -0
  138. package/dist/zag.cjs.js.map +1 -0
  139. package/dist/zag.esm.js +405 -0
  140. package/dist/zag.esm.js.map +1 -0
  141. package/llms.txt +172 -103
  142. package/package.json +112 -14
  143. package/src/ai.d.ts +1186 -0
  144. package/src/ai.ts +23 -0
  145. package/src/astro/client.ts +27 -16
  146. package/src/astro/index.d.ts +24 -0
  147. package/src/astro/index.ts +69 -3
  148. package/src/astro/server.ts +8 -2
  149. package/src/collection.ts +6 -93
  150. package/src/core/actions.ts +134 -0
  151. package/src/core/cell.ts +202 -0
  152. package/src/core/debug.ts +23 -0
  153. package/src/core/define.ts +242 -0
  154. package/src/core/hooks.ts +314 -0
  155. package/src/core/hosts/collection.ts +325 -0
  156. package/src/core/hosts/switchable.ts +146 -0
  157. package/src/core/instance.ts +592 -0
  158. package/src/core/markers/clientonly.ts +13 -0
  159. package/src/core/markers/lazy.ts +40 -0
  160. package/src/core/markers/portal.ts +96 -0
  161. package/src/core/markers/suspense.ts +41 -0
  162. package/src/core/markers/transition.ts +70 -0
  163. package/src/core/registry.ts +25 -0
  164. package/src/core/runtime.ts +573 -0
  165. package/src/core/statics.ts +104 -0
  166. package/src/core/teardown.ts +60 -0
  167. package/src/core/view.ts +37 -0
  168. package/src/cycle/dom/DocumentDOMSource.ts +13 -8
  169. package/src/cycle/dom/ElementFinder.ts +7 -9
  170. package/src/cycle/dom/EventDelegator.ts +174 -223
  171. package/src/cycle/dom/IsolateModule.ts +65 -35
  172. package/src/cycle/dom/MainDOMSource.ts +9 -2
  173. package/src/cycle/dom/PriorityQueue.ts +4 -2
  174. package/src/cycle/dom/SymbolTree.ts +18 -47
  175. package/src/cycle/dom/classNameModule.ts +9 -7
  176. package/src/cycle/dom/controlledInputModule.ts +23 -6
  177. package/src/cycle/dom/enrichEventStream.ts +25 -47
  178. package/src/cycle/dom/fragment.ts +7 -0
  179. package/src/cycle/dom/isolate.ts +30 -27
  180. package/src/cycle/dom/makeDOMDriver.ts +115 -56
  181. package/src/cycle/dom/mockDOMSource.ts +21 -1
  182. package/src/cycle/dom/modules.ts +16 -3
  183. package/src/cycle/dom/propsModule.ts +65 -0
  184. package/src/cycle/dom/selectModule.ts +22 -17
  185. package/src/cycle/dom/snabbdom.ts +1 -6
  186. package/src/cycle/dom/thunk.ts +3 -0
  187. package/src/cycle/dom/utils.ts +98 -0
  188. package/src/cycle/dom/viewTransition.ts +43 -0
  189. package/src/cycle/state/StateSource.ts +19 -3
  190. package/src/cycle/state/objIsEqual.ts +50 -0
  191. package/src/cycle/state/types.ts +0 -10
  192. package/src/defineComponent.ts +34 -0
  193. package/src/devtools.d.ts +170 -0
  194. package/src/devtools.ts +37 -0
  195. package/src/element.d.ts +59 -0
  196. package/src/element.ts +235 -0
  197. package/src/extra/ai/agent/index.ts +484 -0
  198. package/src/extra/ai/answers.ts +115 -0
  199. package/src/extra/ai/chat/behavior.ts +336 -0
  200. package/src/extra/ai/chat/driver.ts +271 -0
  201. package/src/extra/ai/chat/memoryTransport.ts +61 -0
  202. package/src/extra/ai/chat/output.ts +37 -0
  203. package/src/extra/ai/commandBar.ts +281 -0
  204. package/src/extra/ai/decide.ts +104 -0
  205. package/src/extra/ai/index.ts +42 -0
  206. package/src/extra/ai/link.ts +90 -0
  207. package/src/extra/ai/mcpApp.ts +314 -0
  208. package/src/extra/ai/messages.ts +51 -0
  209. package/src/extra/ai/schema/index.ts +166 -0
  210. package/src/extra/ai/schema/jsonSchema.ts +72 -0
  211. package/src/extra/ai/schema/strict.ts +152 -0
  212. package/src/extra/ai/transports/agui.ts +177 -0
  213. package/src/extra/ai/transports/anthropicMessages.ts +175 -0
  214. package/src/extra/ai/transports/chatCompletions.ts +105 -0
  215. package/src/extra/ai/transports/chromePrompt.ts +77 -0
  216. package/src/extra/ai/transports/encodeOpenResponses.ts +87 -0
  217. package/src/extra/ai/transports/fromAISDK.ts +126 -0
  218. package/src/extra/ai/transports/openResponses.ts +105 -0
  219. package/src/extra/ai/transports/shared.ts +125 -0
  220. package/src/extra/ai/transports/tools.ts +59 -0
  221. package/src/extra/ai/transports/uiMessageStream.ts +107 -0
  222. package/src/extra/ai/webmcp.ts +260 -0
  223. package/src/extra/backoff.ts +9 -0
  224. package/src/extra/behaviors.ts +169 -0
  225. package/src/extra/browserSignals.ts +23 -0
  226. package/src/extra/browserSources.ts +326 -0
  227. package/src/extra/command.ts +3 -3
  228. package/src/extra/controls.ts +47 -0
  229. package/src/extra/copyAsTest.ts +286 -0
  230. package/src/extra/devMcp.ts +386 -0
  231. package/src/extra/devtools.ts +101 -31
  232. package/src/extra/devtoolsActions.ts +379 -0
  233. package/src/extra/devtoolsHook.ts +9 -0
  234. package/src/extra/devtoolsNext.ts +83 -0
  235. package/src/extra/diagnostics/checks/actionLog.ts +108 -0
  236. package/src/extra/diagnostics/checks/behaviors.ts +49 -0
  237. package/src/extra/diagnostics/checks/browserSources.ts +96 -0
  238. package/src/extra/diagnostics/checks/chat.ts +71 -0
  239. package/src/extra/diagnostics/checks/collections.ts +1 -1
  240. package/src/extra/diagnostics/checks/controls.ts +148 -0
  241. package/src/extra/diagnostics/checks/dataset.ts +57 -0
  242. package/src/extra/diagnostics/checks/dom.ts +20 -11
  243. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  244. package/src/extra/diagnostics/checks/events.ts +17 -2
  245. package/src/extra/diagnostics/checks/fetch.ts +135 -0
  246. package/src/extra/diagnostics/checks/forms.ts +171 -0
  247. package/src/extra/diagnostics/checks/index.ts +103 -4
  248. package/src/extra/diagnostics/checks/inspect.ts +140 -32
  249. package/src/extra/diagnostics/checks/next.ts +417 -0
  250. package/src/extra/diagnostics/checks/persist.ts +54 -0
  251. package/src/extra/diagnostics/checks/props.ts +10 -5
  252. package/src/extra/diagnostics/checks/public.d.ts +115 -7
  253. package/src/extra/diagnostics/checks/replies.ts +128 -0
  254. package/src/extra/diagnostics/checks/router.ts +100 -0
  255. package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
  256. package/src/extra/diagnostics/checks/shared.ts +86 -2
  257. package/src/extra/diagnostics/checks/shorthand.ts +115 -0
  258. package/src/extra/diagnostics/checks/sortable.ts +129 -0
  259. package/src/extra/diagnostics/checks/state.ts +100 -7
  260. package/src/extra/diagnostics/checks/statics.ts +48 -0
  261. package/src/extra/diagnostics/checks/strict.ts +10 -67
  262. package/src/extra/diagnostics/checks/timers.ts +80 -0
  263. package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
  264. package/src/extra/diagnostics/checks/virtual.ts +73 -0
  265. package/src/extra/diagnostics/checks/widgets.ts +109 -0
  266. package/src/extra/diagnostics/checks/wiring.ts +69 -10
  267. package/src/extra/diagnostics/codes.ts +335 -23
  268. package/src/extra/diagnostics/index.ts +34 -9
  269. package/src/extra/diagnostics/legacy.ts +17 -0
  270. package/src/extra/driverFactories.ts +83 -19
  271. package/src/extra/elementCommands.ts +53 -0
  272. package/src/extra/fetchDriver.ts +519 -0
  273. package/src/extra/focusWithin.ts +27 -0
  274. package/src/extra/form.ts +284 -0
  275. package/src/extra/formHelpers.ts +139 -0
  276. package/src/extra/formTool.ts +150 -0
  277. package/src/extra/head.ts +106 -0
  278. package/src/extra/hmr.ts +2 -3
  279. package/src/extra/owned.ts +24 -0
  280. package/src/extra/pager.ts +50 -0
  281. package/src/extra/persist.ts +133 -0
  282. package/src/extra/pwa.ts +1 -1
  283. package/src/extra/queryCache.ts +107 -0
  284. package/src/extra/reducers.ts +1 -1
  285. package/src/extra/reduxDevtools.ts +92 -0
  286. package/src/extra/replies.ts +54 -0
  287. package/src/extra/router.ts +323 -0
  288. package/src/extra/run.ts +90 -217
  289. package/src/extra/selection.ts +76 -0
  290. package/src/extra/socketDriver.ts +265 -0
  291. package/src/extra/sortable.ts +377 -0
  292. package/src/extra/ssr.ts +427 -160
  293. package/src/extra/standardSchema.ts +27 -0
  294. package/src/extra/testing.ts +2550 -175
  295. package/src/extra/timers.ts +95 -0
  296. package/src/extra/undo.ts +261 -0
  297. package/src/extra/viewTransitions.ts +38 -0
  298. package/src/extra/virtual.ts +513 -0
  299. package/src/extra/widget.ts +201 -0
  300. package/src/index.d.ts +2754 -127
  301. package/src/index.ts +45 -4
  302. package/src/jsx-runtime.ts +15 -1
  303. package/src/jsx.ts +2 -1
  304. package/src/lazy.ts +50 -7
  305. package/src/portal.ts +3 -1
  306. package/src/pragma/index.ts +256 -133
  307. package/src/react-peers.d.ts +5 -0
  308. package/src/react.d.ts +43 -0
  309. package/src/react.ts +110 -0
  310. package/src/shared.ts +57 -0
  311. package/src/slot.ts +1 -1
  312. package/src/suspense.ts +3 -1
  313. package/src/switchable.ts +6 -121
  314. package/src/transition.ts +3 -1
  315. package/src/ui/accordion.ts +64 -0
  316. package/src/ui/dialog.ts +129 -0
  317. package/src/ui/disclosure.ts +35 -0
  318. package/src/ui/popover.ts +45 -0
  319. package/src/ui/shared.ts +94 -0
  320. package/src/ui/tabs.ts +82 -0
  321. package/src/ui/toaster.ts +198 -0
  322. package/src/ui/tooltip.ts +70 -0
  323. package/src/ui/zag/combobox.ts +115 -0
  324. package/src/ui/zag/menu.ts +40 -0
  325. package/src/ui/zag/select.ts +54 -0
  326. package/src/ui/zag/shared.ts +43 -0
  327. package/src/ui-combobox.d.ts +20 -0
  328. package/src/ui-combobox.ts +6 -0
  329. package/src/ui-menu.d.ts +30 -0
  330. package/src/ui-menu.ts +6 -0
  331. package/src/ui-select.d.ts +17 -0
  332. package/src/ui-select.ts +6 -0
  333. package/src/ui-zag-types.d.ts +42 -0
  334. package/src/ui.d.ts +208 -0
  335. package/src/ui.ts +15 -0
  336. package/src/vike/+config.ts +9 -1
  337. package/src/vike/ClientOnly.ts +1 -1
  338. package/src/vike/onRenderClient.ts +144 -96
  339. package/src/vike/onRenderHtml.ts +69 -26
  340. package/src/vike/types.ts +7 -1
  341. package/src/vite/globalthis-shim.ts +18 -0
  342. package/src/vite/globalthis.ts +56 -0
  343. package/src/vite/mcp.ts +568 -0
  344. package/src/vite/plugin.d.ts +47 -3
  345. package/src/vite/plugin.ts +262 -25
  346. package/src/zag.d.ts +64 -0
  347. package/src/zag.ts +238 -0
  348. package/dist/vike/+config.cjs.js +0 -66
  349. package/dist/vike/+config.cjs.js.map +0 -1
  350. package/dist/vike/+config.js.map +0 -1
  351. package/src/component.ts +0 -2301
  352. package/src/cycle/isolate/index.ts +0 -196
  353. package/src/cycle/run/index.ts +0 -151
  354. package/src/cycle/run/internals.ts +0 -143
  355. package/src/cycle/state/Collection.ts +0 -184
  356. package/src/cycle/state/index.ts +0 -5
  357. package/src/cycle/state/pickCombine.ts +0 -201
  358. package/src/cycle/state/pickMerge.ts +0 -122
  359. package/src/cycle/state/withState.ts +0 -47
  360. package/src/pragma/fn.ts +0 -55
@@ -0,0 +1,588 @@
1
+ <!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
2
+ # Agents
3
+
4
+ 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.
5
+
6
+ One declaration serves every kind of agent:
7
+
8
+ - the [in-app assistant](#an-in-app-assistant-chat): the `chat` behavior, a chat model that operates the component it lives in;
9
+ - the [command bar](#a-command-bar-commandbar): one line of text, one action, picked by a decision model;
10
+ - the browser's own agent, through [WebMCP](./webmcp.md) (experimental);
11
+ - an MCP host showing your app as an [MCP App](./mcp-apps.md);
12
+ - a coding agent, through the [dev server MCP endpoint](https://sygnal.js.org/integration/agents/#dev-server-mcp-endpoint);
13
+ - your tests: [`t.tools()`, `t.callTool()` and `t.agentContext()`](#testing).
14
+
15
+ Everything on this page is imported from `sygnal/ai`, except `abort` (from `sygnal`). An app that doesn't use it pays nothing.
16
+
17
+ ## The `agent` static
18
+
19
+ ```jsx
20
+ // TodoApp.jsx
21
+ import { z } from 'zod'
22
+ import { Collection } from 'sygnal'
23
+ import TodoItem from './TodoItem.jsx'
24
+
25
+ export default function TodoApp({ state }) {
26
+ return (
27
+ <main>
28
+ <ul className="todos"><Collection of={TodoItem} from="todos" /></ul>
29
+ <button type="button" className="clear" disabled={!state.todos.some((t) => t.done)}>Clear done</button>
30
+ </main>
31
+ )
32
+ }
33
+
34
+ TodoApp.initialState = { todos: [], filter: 'all', nextId: 1 }
35
+
36
+ TodoApp.intent = ({ DOM }) => ({ CLEAR_DONE: DOM.click('.clear') })
37
+
38
+ TodoApp.model = {
39
+ ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }),
40
+ SET_FILTER: (state, filter) => ({ ...state, filter }),
41
+ CLEAR_DONE: (state) => ({ ...state, todos: state.todos.filter((t) => !t.done) }),
42
+ }
43
+
44
+ TodoApp.agent = {
45
+ name: 'todos', // tool names: todos_add, todos_set_filter, ..., todos_read
46
+ description: 'The todo list',
47
+ untrusted: true, // read() returns text the user typed
48
+ // what the model sees of this component
49
+ read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }),
50
+ actions: {
51
+ ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') },
52
+ SET_FILTER: { description: 'Which todos to show', input: z.enum(['all', 'active', 'done']), idempotent: true },
53
+ CLEAR_DONE: {
54
+ description: 'Delete every todo that is done',
55
+ consequential: true, // the user confirms first
56
+ when: (state) => state.todos.some((t) => t.done), // offered only while it can do something
57
+ },
58
+ },
59
+ }
60
+ ```
61
+
62
+ `ADD` and `SET_FILTER` have no intent trigger here: they exist for agents, and an `agent.actions` entry counts as a trigger for [SYG102](https://sygnal.js.org/reference/errors/#syg102).
63
+
64
+ | Key | What it does |
65
+ |---|---|
66
+ | `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](https://sygnal.js.org/reference/errors/#syg440) |
67
+ | `description` | What the component is, for the model |
68
+ | `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 |
69
+ | `label(state)` | A [Collection item](#collection-items)'s label for the model |
70
+ | `untrusted` | `read` returns text users typed (see [Security](#security)). Unset, Sygnal infers it from the strings in the projection |
71
+ | `actions` | The only actions agents may run, by action name |
72
+
73
+ Each entry of `actions`:
74
+
75
+ | Key | What it does |
76
+ |---|---|
77
+ | `description` | What the action does, in the app's own words (at most 500 characters for WebMCP) |
78
+ | `input` | The schema of the action's data. Without one the action takes no arguments |
79
+ | `consequential` | Ask the user before running it |
80
+ | `when(state)` | The tool is offered only while this is true |
81
+ | `idempotent` | A call that changes nothing is a success (`{ ok: true, unchanged: true }`), not an error |
82
+
83
+ ### Input schemas
84
+
85
+ `input` is any [Standard Schema](https://standardschema.dev) 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.
86
+
87
+ Plain JSON Schema goes through `jsonSchema()`, which checks the common keywords itself (`type`, `enum`, `required`, `properties`, `minLength`, `pattern`, ...):
88
+
89
+ ```jsx
90
+ import { jsonSchema } from 'sygnal/ai'
91
+
92
+ const input = jsonSchema({ type: 'object', properties: { text: { type: 'string', minLength: 1 } }, required: ['text'] })
93
+ ```
94
+
95
+ 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](#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()`:
96
+
97
+ ```jsx
98
+ import { type } from 'arktype'
99
+
100
+ const input = type({ id: type('string').configure({ description: 'The todo id', expected: 'a string' }) })
101
+ // { id: 3 } → "id must be a string (was a number)"; the model still sees the description
102
+ ```
103
+
104
+ `.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.
105
+
106
+ A schema with no JSON Schema form at all (a Zod `z.date()`, a `refine` on the root) is [SYG240](https://sygnal.js.org/reference/errors/#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](https://sygnal.js.org/reference/errors/#syg243): the model doesn't see that part, but it is still checked.
107
+
108
+ In TypeScript, on a component typed with its actions ([TypeScript](https://sygnal.js.org/integration/typescript/)), `input` is required for an action that takes data, and its schema's output must fit that data.
109
+
110
+ ## What agents see
111
+
112
+ **Tools.** One per listed action, plus `<name>_read` when the declaration has a `read`. The todo app above offers:
113
+
114
+ | Tool | Input | Hints |
115
+ |---|---|---|
116
+ | `todos_read` | none | read-only |
117
+ | `todos_add` | `{ value: string }` | |
118
+ | `todos_set_filter` | `{ value: 'all' \| 'active' \| 'done' }` | |
119
+ | `todos_clear_done` | none; only while a todo is done | consequential |
120
+ | `todo_toggle`, `todo_remove` | `{ id }`, from `TodoItem`'s declaration ([below](#collection-items)) | `todo_remove`: consequential |
121
+
122
+ Only the components on the page count: a component that isn't rendered (a hidden [Switchable](https://sygnal.js.org/guide/switchable/) page, a closed panel) offers no tools. The list follows the app after every render.
123
+
124
+ **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.
125
+
126
+ ## The call rules
127
+
128
+ Every call, from any kind of agent, goes through the same steps:
129
+
130
+ 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.
131
+ 2. **Availability.** A tool whose `when` is false now gets `{ ok: false, error: 'todos_clear_done is not available now' }`.
132
+ 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
+ 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).
135
+
136
+ **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
+
138
+ ```text
139
+ { ok: false, error: 'SET_FILTER changed nothing (already so, or the input matched nothing)', state: {…} }
140
+ ```
141
+
142
+ 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 }`.
143
+
144
+ **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.
145
+
146
+ ```jsx
147
+ import { abort } from 'sygnal'
148
+
149
+ TodoApp.model = {
150
+ ADD: (state, text) => state.todos.length >= 50
151
+ ? abort('the list is full (50 todos); remove some first')
152
+ : { ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 },
153
+ }
154
+ // → { ok: false, error: 'ADD was refused: the list is full (50 todos); remove some first', state }
155
+ ```
156
+
157
+ A refusal is reported even on an `idempotent` action.
158
+
159
+ **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
+
161
+ ## Collection items
162
+
163
+ 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.
164
+
165
+ ```jsx live-file=./TodoItem.jsx
166
+ export default function TodoItem({ state }) {
167
+ return (
168
+ <li>
169
+ <label><input type="checkbox" className="done" checked={state.done} /> {state.text}</label>
170
+ <button type="button" className="remove" aria-label={`Remove ${state.text}`}>×</button>
171
+ </li>
172
+ )
173
+ }
174
+
175
+ TodoItem.intent = ({ DOM }) => ({ TOGGLE: DOM.change('.done'), REMOVE: DOM.click('.remove') })
176
+
177
+ TodoItem.model = {
178
+ TOGGLE: (state) => ({ ...state, done: !state.done }),
179
+ REMOVE: () => undefined,
180
+ }
181
+
182
+ TodoItem.agent = {
183
+ name: 'todo', // todo_toggle({ id }), todo_remove({ id })
184
+ description: 'One todo',
185
+ label: (state) => state.text, // "buy milk" for the item with id 1 (see below)
186
+ actions: {
187
+ TOGGLE: { description: 'Mark the todo done, or not done again' },
188
+ REMOVE: { description: 'Delete the todo', consequential: true },
189
+ },
190
+ }
191
+ ```
192
+
193
+ (This block is a module the demos on this page import, `./TodoItem.jsx`.)
194
+
195
+ - The parameter is `id`, or `item` when the action's input has its own `id` field.
196
+ - `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](#an-in-app-assistant-chat) as `todo_labels` (`{ "1": "buy milk", "2": "water plants" }`), in the [command bar's](#a-command-bar-commandbar) 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](#security).
197
+ - The keys come from the items' `state.id`. Items without ids, or two items with one id, are [SYG441](https://sygnal.js.org/reference/errors/#syg441).
198
+ - 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] }`.
199
+
200
+ **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:
201
+
202
+ ```jsx
203
+ import { z } from 'zod'
204
+ import { abort } from 'sygnal'
205
+
206
+ TodoApp.model = {
207
+ SET_DONE: (state, { id, done }) => state.todos.some((t) => t.id === id)
208
+ ? { ...state, todos: state.todos.map((t) => (t.id === id ? { ...t, done } : t)) }
209
+ : abort(`no todo with id ${id}`),
210
+ }
211
+
212
+ TodoApp.agent = {
213
+ name: 'todos',
214
+ read: (state) => ({ todos: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }),
215
+ actions: {
216
+ SET_DONE: {
217
+ description: 'Mark a todo done or not done, including todos the filter hides',
218
+ input: z.object({ id: z.number().int(), done: z.boolean() }),
219
+ },
220
+ },
221
+ }
222
+ ```
223
+
224
+ The owner's `read` lists every todo, so the model has the ids either way.
225
+
226
+ ## An in-app assistant: `chat`
227
+
228
+ 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](#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](./ai-chat.md); the behavior runs the [tool loop](./ai-chat.md#tools) you would otherwise write yourself.
229
+
230
+ 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).
231
+
232
+ ```js live-server
233
+ import { encodeOpenResponses } from 'sygnal/ai'
234
+
235
+ const words = (text) => text.match(/\S+\s*/g)
236
+ const reply = (chunks) => ({ sse: encodeOpenResponses(chunks), delayMs: 300, chunkMs: 40 })
237
+ const textOf = (item) => (typeof item.content === 'string' ? item.content : (item.content ?? []).map((c) => c.text).join(''))
238
+
239
+ export default {
240
+ 'POST /v1/responses': ({ json }) => {
241
+ const last = json.input.at(-1)
242
+ // a tool ran: answer with its result
243
+ if (last.type === 'function_call_output') {
244
+ const result = JSON.parse(last.output)
245
+ if (!result.ok) return reply(words(`That did not work: ${result.error}.`))
246
+ const open = (result.state?.items ?? []).filter((t) => !t.done).length
247
+ return reply(words(`Done. ${open} todo(s) still open.`))
248
+ }
249
+ // the app state: a message the chat behavior sends before the user's, JSON between <app-state> tags
250
+ const stateMessage = json.input.find((item) => textOf(item).includes('<app-state>'))
251
+ const app = JSON.parse(textOf(stateMessage).split('<app-state>')[1].split('</app-state>')[0])
252
+ const asked = textOf(last)
253
+ const named = app.todos.items.find((t) => asked.toLowerCase().includes(t.text.toLowerCase()))
254
+ if (/^add /i.test(asked)) return reply([{ toolCall: { name: 'todos_add', input: { value: asked.slice(4) } } }])
255
+ if (named && /remove|delete/i.test(asked)) return reply([{ toolCall: { name: 'todo_remove', input: { id: named.id } } }])
256
+ if (named) return reply([{ toolCall: { name: 'todo_toggle', input: { id: named.id } } }])
257
+ return reply(words('Try "add call mom", "buy milk is done" or "remove buy milk".'))
258
+ },
259
+ }
260
+ ```
261
+
262
+ ```jsx live live-height=380
263
+ import { z } from 'zod'
264
+ import { Collection } from 'sygnal'
265
+ import { chat, messageText } from 'sygnal/ai'
266
+ import TodoItem from './TodoItem.jsx'
267
+
268
+ // the tools a message used, after its text
269
+ const toolsOf = (m) => m.parts.filter((p) => p.type.startsWith('tool-')).map((p) => ` [${p.type.slice(5)}]`).join('')
270
+
271
+ // The assistant's markup: a plain function the view calls, so the behavior's selectors
272
+ // (.ask, .prompt, .approve, ...) are in the host's own view
273
+ function assistantPanel(a) {
274
+ const busy = a.status === 'submitted' || a.status === 'streaming'
275
+ return (
276
+ <aside className="assistant">
277
+ <ol className="conversation" aria-live="polite">
278
+ {a.messages.map((m, i) => <li key={i} className={m.role}><b>{m.role}:</b> {messageText(m)}{toolsOf(m)}</li>)}
279
+ {a.draft && <li className="assistant"><b>assistant:</b> {a.draft}</li>}
280
+ </ol>
281
+ {a.pending && (
282
+ <div role="alertdialog" aria-label="Confirm">
283
+ <p>The assistant wants to: {a.pending.description} ({a.pending.label})</p>
284
+ <button type="button" className="approve">Allow</button>
285
+ <button type="button" className="deny">Deny</button>
286
+ </div>
287
+ )}
288
+ {a.error && <p role="alert">{a.error}</p>}
289
+ <form className="ask">
290
+ <label>Ask <input className="prompt" value={a.prompt} /></label>
291
+ <button type="submit" disabled={busy}>Send</button>
292
+ <button type="button" className="stop" hidden={!busy}>Stop</button>
293
+ </form>
294
+ </aside>
295
+ )
296
+ }
297
+
298
+ function TodoApp({ state }) {
299
+ return (
300
+ <main>
301
+ <ul className="todos"><Collection of={TodoItem} from="todos" /></ul>
302
+ {assistantPanel(state.assistant)}
303
+ </main>
304
+ )
305
+ }
306
+
307
+ TodoApp.initialState = {
308
+ todos: [{ id: 1, text: 'buy milk', done: false }, { id: 2, text: 'water plants', done: false }],
309
+ nextId: 3,
310
+ }
311
+
312
+ TodoApp.model = {
313
+ // the demo's first question, as soon as it shows
314
+ BOOTSTRAP: { EFFECT: (state, data, next) => next('assistant.SEND', 'water plants is done') },
315
+ ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }),
316
+ }
317
+
318
+ TodoApp.agent = {
319
+ name: 'todos',
320
+ description: 'The todo list',
321
+ untrusted: true,
322
+ read: (state) => ({ items: state.todos.map(({ id, text, done }) => ({ id, text, done })) }),
323
+ actions: {
324
+ ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') },
325
+ },
326
+ }
327
+
328
+ TodoApp.uses = {
329
+ assistant: chat({
330
+ form: '.ask', prompt: '.prompt', stop: '.stop', approve: '.approve', deny: '.deny',
331
+ instructions: 'You manage this todo list with the tools. Keep replies short.',
332
+ }),
333
+ }
334
+
335
+ export default TodoApp
336
+ ```
337
+
338
+ The demo starts its assistant from `BOOTSTRAP` only so it has something to show; a real assistant waits for the user.
339
+
340
+ In an app, `main.js` gives the chat driver a transport to a model. Locally, an Ollama model:
341
+
342
+ ```js
343
+ import { run } from 'sygnal'
344
+ import { makeChatDriver, openResponses } from 'sygnal/ai'
345
+ import TodoApp from './TodoApp.jsx'
346
+
347
+ run(TodoApp, {
348
+ LLM: makeChatDriver({ transport: openResponses({ baseURL: 'http://localhost:11434/v1', model: 'qwen3:8b' }) }),
349
+ })
350
+ ```
351
+
352
+ 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.
353
+
354
+ ### Options and state
355
+
356
+ `chat(options)`:
357
+
358
+ | Option | Default | What it does |
359
+ |---|---|---|
360
+ | `form` | | The form whose submit sends the prompt |
361
+ | `prompt` | | The prompt field (its input sets `prompt`) |
362
+ | `stop`, `approve`, `deny`, `regenerate` | | Buttons: their clicks dispatch `STOP`, `APPROVE`, `DENY`, `REGENERATE` |
363
+ | `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) |
365
+ | `maxSteps` | `8` | The most requests per turn; tool calls in a reply past it are not run |
366
+ | `sink` | `'LLM'` | The chat driver's name |
367
+ | `model`, `transportOptions` | | A model id, and extra request keys for the transport |
368
+
369
+ The slice, `state.assistant` for `uses = { assistant: chat(...) }`:
370
+
371
+ | Field | |
372
+ |---|---|
373
+ | `messages` | The conversation: AI SDK `UIMessage`s. The model's replies hold `tool-<name>` parts with each call's input and result; `messageText(m)` gives a message's text |
374
+ | `prompt` | The prompt field's value |
375
+ | `draft`, `draftReasoning` | The text and reasoning of the reply being streamed (`''` otherwise) |
376
+ | `status` | `'ready'`, `'submitted'` (sent, nothing back yet), `'streaming'` (receiving, or running tools) or `'error'` |
377
+ | `pending` | A consequential call waiting for `APPROVE` / `DENY`: `{ tool, component, action, description, input, key?, label? }`, else `null` |
378
+ | `error` | The last failure's message, else `null` |
379
+
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).
381
+
382
+ **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
+
384
+ **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](./ai-chat.md#storing-conversations). 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.)
385
+
386
+ **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.
387
+
388
+ ## A command bar: `commandBar`
389
+
390
+ 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](#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](./ai-decisions.md).
391
+
392
+ 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).
393
+
394
+ ```js live-server
395
+ import { answers } from 'sygnal/ai'
396
+
397
+ export default {
398
+ 'POST /v1/systemone': ({ json }) => {
399
+ const { command, app } = json.state
400
+ const said = command.toLowerCase()
401
+ const named = app.todos.items.find((t) => said.includes(t.text))
402
+ const filter = ['all', 'active', 'done'].find((f) => said.startsWith(`show ${f}`))
403
+ const action =
404
+ said.startsWith('add ') ? { choice: 'todos_add', confidence: 0.94 }
405
+ : filter ? { choice: `todos_set_filter=${filter}`, confidence: 0.9 }
406
+ : named && /remove|delete/.test(said) ? { choice: 'todo_remove', confidence: 0.9 }
407
+ : named ? { choice: 'todo_toggle', confidence: 0.88 }
408
+ : /finished|done/.test(said) ? { choice: 'todos_set_filter=done', confidence: 0.43 }
409
+ : { choice: 'none', confidence: 0.7 }
410
+ const target = json.questions.target && { choice: named ? String(named.id) : 'none', confidence: 0.9 }
411
+ // answers(): the reply a decision model sends for these questions
412
+ return { delayMs: 400, json: answers(json.questions, { action, ...(target && { target }) }, { model: json.model }) }
413
+ },
414
+ }
415
+ ```
416
+
417
+ ```jsx live live-height=300
418
+ import { z } from 'zod'
419
+ import { Collection } from 'sygnal'
420
+ import { commandBar } from 'sygnal/ai'
421
+ import TodoItem from './TodoItem.jsx'
422
+
423
+ function outcome(cmd) {
424
+ if (cmd.status === 'deciding') return 'Deciding…'
425
+ 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.`
426
+ if (cmd.error) return `Failed: ${cmd.error}`
427
+ if (cmd.result) return cmd.result.ok ? `"${cmd.result.command}" ran ${cmd.result.tool}.` : `"${cmd.result.command}": ${cmd.result.error}`
428
+ return ''
429
+ }
430
+
431
+ function Commands({ state }) {
432
+ const cmd = state.cmd
433
+ return (
434
+ <main>
435
+ <form className="command-form">
436
+ <label>Command <input className="command" value={cmd.text} /></label>
437
+ <button type="submit" disabled={cmd.status !== 'ready'}>Run</button>
438
+ </form>
439
+ <p className="outcome" aria-live="polite">{outcome(cmd)}</p>
440
+ {cmd.pending && (
441
+ <div role="alertdialog" aria-label="Confirm">
442
+ <p>Run "{cmd.pending.description}" on {cmd.pending.label}?</p>
443
+ <button type="button" className="cmd-approve">Allow</button>
444
+ <button type="button" className="cmd-deny">Deny</button>
445
+ </div>
446
+ )}
447
+ <p>Showing: {state.filter}</p>
448
+ <ul className="todos">
449
+ <Collection of={TodoItem} from="todos" filter={(t) => state.filter === 'all' || t.done === (state.filter === 'done')} />
450
+ </ul>
451
+ </main>
452
+ )
453
+ }
454
+
455
+ Commands.initialState = {
456
+ todos: [{ id: 1, text: 'buy milk', done: false }, { id: 2, text: 'water plants', done: false }],
457
+ filter: 'all',
458
+ nextId: 3,
459
+ }
460
+
461
+ Commands.model = {
462
+ // the demo's first command, as soon as it shows
463
+ BOOTSTRAP: { EFFECT: (state, data, next) => next('cmd.RUN', 'mark water plants done') },
464
+ ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 }),
465
+ SET_FILTER: (state, filter) => ({ ...state, filter }),
466
+ }
467
+
468
+ Commands.agent = {
469
+ name: 'todos',
470
+ description: 'The todo list',
471
+ untrusted: true,
472
+ read: (state) => ({ items: state.todos.map(({ id, text, done }) => ({ id, text, done })), filter: state.filter }),
473
+ actions: {
474
+ ADD: { description: 'Add a todo', input: z.string().min(1).describe('The todo text') },
475
+ SET_FILTER: { description: 'Which todos to show', input: z.enum(['all', 'active', 'done']), idempotent: true },
476
+ },
477
+ }
478
+
479
+ Commands.uses = {
480
+ cmd: commandBar({
481
+ input: '.command', form: '.command-form', approve: '.cmd-approve', deny: '.cmd-deny',
482
+ decide: { url: '/v1/systemone', model: 'nimble' },
483
+ }),
484
+ }
485
+
486
+ export default Commands
487
+ ```
488
+
489
+ What it asks the decision model, as one `decide()` request through the app's `HTTP` driver:
490
+
491
+ - **action**: a `choice` over the offered actions, described by their `description`s, 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.
492
+ - **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.
493
+ - The decision's `state` is `{ command, app, labels }`: `app` is the `read` projections, `labels` the items' labels by option.
494
+
495
+ `commandBar(options)`:
496
+
497
+ | Option | Default | What it does |
498
+ |---|---|---|
499
+ | `input` | required | The command field. Enter runs it, unless `form` is given |
500
+ | `form` | | A form whose submit runs the command |
501
+ | `run` | | A button whose click runs the field's command (a Go button) |
502
+ | `decide` | required | The decision request: `{ url, model }` (plus any [`makeFetchDriver`](./http.md) request keys), or a function `(q) => decide.openai({ ...q, url, model })` |
503
+ | `below` | `0.6` | Below this confidence (the action's, and the target's for an item action) nothing runs |
504
+ | `escalate` | | The `uses` key of a `chat` behavior on the same host: an unsure command goes to its `SEND` |
505
+ | `approve`, `deny` | | Buttons for a consequential action |
506
+ | `freeText` | a heuristic | `(command, tool) => string \| undefined`: the text argument of an action whose input is one string |
507
+ | `agent` | the host's and its shown descendants' | `[Comp, ...]`: only these components' actions |
508
+ | `sink` | `'HTTP'` | The fetch driver's name |
509
+
510
+ 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).
511
+
512
+ **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:
513
+
514
+ ```jsx
515
+ TodoApp.uses = {
516
+ assistant: chat({ form: '.ask', prompt: '.prompt', instructions: 'You manage this todo list with the tools.' }),
517
+ cmd: commandBar({ input: '.command', decide: { url: '/api/decide', model: 'jev-latest' }, below: 0.6, escalate: 'assistant' }),
518
+ }
519
+ ```
520
+
521
+ `result` is then `{ command, escalated: 'assistant', reason, ... }`.
522
+
523
+ **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.
524
+
525
+ 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`).
526
+
527
+ ## Security
528
+
529
+ Treat every tool call as untrusted input from someone holding the user's session, because that is what it is.
530
+
531
+ - **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.
532
+ - **Agents see projections, not state.** `read` decides what leaves the app. Leave out tokens, other users' data, and anything the task doesn't need.
533
+ - **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.
534
+ - **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](./webmcp.md#hints)).
535
+ - **No user text in descriptions or instructions.** Models follow what tool descriptions and system instructions say. Write `description`s 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).
536
+ - **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](https://sygnal.js.org/reference/errors/#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`.
537
+ - **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.
538
+
539
+ 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.
540
+
541
+ ## Testing
542
+
543
+ `renderComponent` ([Testing](https://sygnal.js.org/integration/testing/)) runs agent calls as an agent does, with no model:
544
+
545
+ ```js
546
+ import { it, expect, afterEach } from 'vitest'
547
+ import { renderComponent } from 'sygnal'
548
+ import TodoApp from './TodoApp.jsx'
549
+
550
+ let t
551
+ afterEach(() => t?.dispose())
552
+
553
+ it('is operable by agents', async () => {
554
+ t = renderComponent(TodoApp, { initialState: { todos: [{ id: 1, text: 'buy milk', done: false }], filter: 'all', nextId: 2 } })
555
+ await t.ready()
556
+
557
+ // the offered tools: name, description, inputSchema, annotations
558
+ expect(t.tools().map((tool) => tool.name)).toEqual(['todos_read', 'todos_add', 'todos_set_filter', 'todo_toggle', 'todo_remove'])
559
+ // what the model sees
560
+ expect(t.agentContext()).toEqual({ todos: { todos: [{ id: 1, text: 'buy milk', done: false }], filter: 'all' } })
561
+
562
+ expect(await t.callTool('todos_add', { value: 'walk the dog' })).toMatchObject({ ok: true })
563
+ expect(await t.callTool('todos_add', { value: 42 })).toMatchObject({ ok: false, issues: expect.any(Array) })
564
+ expect(await t.callTool('todo_toggle', { id: 1 })).toMatchObject({ ok: true })
565
+ expect(await t.callTool('todo_toggle', { id: 9 })).toMatchObject({ ok: false, keys: [1, 2] })
566
+
567
+ // a consequential tool needs an answer: { confirm: true | false | (info) => boolean }
568
+ expect(await t.callTool('todos_clear_done', {}, { confirm: false })).toEqual({ ok: false, error: 'the user declined' })
569
+ expect(await t.callTool('todos_clear_done', {}, { confirm: true })).toMatchObject({ ok: true })
570
+
571
+ expect(t.actions.filter((a) => a.cause === 'agent').map((a) => a.type)).toEqual(['ADD', 'TOGGLE', 'CLEAR_DONE'])
572
+ })
573
+ ```
574
+
575
+ - `t.tools()` lists the offered tools, plus any declared tool that can't be offered (SYG240) with its `error`.
576
+ - `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.
577
+ - `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 })`).
578
+
579
+ 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.
580
+
581
+ ## Related
582
+
583
+ - [AI Chat](./ai-chat.md): the chat driver under the `chat` behavior, transports, and the raw tool loop
584
+ - [AI Decisions](./ai-decisions.md): decision models, `decide()` and confidence, which the command bar uses
585
+ - [WebMCP](./webmcp.md): the same tools for the browser's agent (experimental)
586
+ - [MCP Apps](./mcp-apps.md): your app inside Claude, ChatGPT or VS Code, with its tools
587
+ - [Building with AI Agents](https://sygnal.js.org/integration/agents/): coding agents, `sygnal-check` and the dev server endpoint
588
+ - [Accessibility](./accessibility.md): browser agents that read the page use the accessibility tree