experimental-a2 0.16.0 → 0.17.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 (334) hide show
  1. package/CHANGELOG.md +360 -0
  2. package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
  3. package/dist/actor-DBbe5xeE.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +2 -5
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +13 -4
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -3
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +20 -10
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  13. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  14. package/dist/actor.d.ts +1 -1
  15. package/dist/actor.js +42 -23
  16. package/dist/actor.js.map +1 -1
  17. package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
  18. package/dist/ai-UrWfKeuv.js.map +1 -0
  19. package/dist/ai-client.d.ts +27 -0
  20. package/dist/ai-client.d.ts.map +1 -0
  21. package/dist/ai-client.js +29 -0
  22. package/dist/ai-client.js.map +1 -0
  23. package/dist/ai-react.d.ts +96 -0
  24. package/dist/ai-react.d.ts.map +1 -0
  25. package/dist/ai-react.js +142 -0
  26. package/dist/ai-react.js.map +1 -0
  27. package/dist/ai-server.browser.js +2 -2
  28. package/dist/ai-server.browser.js.map +1 -1
  29. package/dist/ai-server.d.ts +80 -36
  30. package/dist/ai-server.d.ts.map +1 -1
  31. package/dist/ai-server.js +466 -265
  32. package/dist/ai-server.js.map +1 -1
  33. package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
  34. package/dist/ai-vpGtcaCB.d.ts.map +1 -0
  35. package/dist/ai.d.ts +2 -2
  36. package/dist/ai.js +2 -2
  37. package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
  38. package/dist/client-COaiaDub.js.map +1 -0
  39. package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
  40. package/dist/client-qRnRUzBS.d.ts.map +1 -0
  41. package/dist/client.d.ts +4 -2
  42. package/dist/client.js +1 -1
  43. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  44. package/dist/contract-BYKASlCV.js.map +1 -0
  45. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  46. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  47. package/dist/index.d.ts +6 -5
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +4 -3
  50. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
  51. package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
  52. package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
  53. package/dist/internal-CbVvEwad.js.map +1 -0
  54. package/dist/otel.d.ts +1 -1
  55. package/dist/react-BbJ-g6Uu.d.ts +86 -0
  56. package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
  57. package/dist/react.d.ts +2 -122
  58. package/dist/react.js +107 -100
  59. package/dist/react.js.map +1 -1
  60. package/dist/reducer-BAXc8JyF.js +87 -0
  61. package/dist/reducer-BAXc8JyF.js.map +1 -0
  62. package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
  63. package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
  64. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  65. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +3 -3
  67. package/dist/scheduler-qstash.d.ts.map +1 -1
  68. package/dist/scheduler-qstash.js +33 -10
  69. package/dist/scheduler-qstash.js.map +1 -1
  70. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  71. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  72. package/dist/scheduler-vercel.d.ts +4 -9
  73. package/dist/scheduler-vercel.d.ts.map +1 -1
  74. package/dist/scheduler-vercel.js +25 -44
  75. package/dist/scheduler-vercel.js.map +1 -1
  76. package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
  77. package/dist/server-CGM8WzRG.d.ts.map +1 -0
  78. package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
  79. package/dist/server-Rj64fNvX.js.map +1 -0
  80. package/dist/server.d.ts +4 -4
  81. package/dist/server.js +2 -2
  82. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  83. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  84. package/dist/session-clear-Dshpuhff.d.ts +12 -0
  85. package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
  86. package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
  87. package/dist/store-Gpkq7WBo.d.ts.map +1 -0
  88. package/dist/store-N8PXxDAS.js.map +1 -1
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +182 -69
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
  94. package/dist/store-polling-4_y8lETm.js.map +1 -0
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +399 -108
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
  100. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.d.ts.map +1 -1
  103. package/dist/store-redis-http.js +293 -11
  104. package/dist/store-redis-http.js.map +1 -1
  105. package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
  106. package/dist/store-redis-notify-tqWNnMRu.js.map +1 -0
  107. package/dist/store-redis.d.ts +1 -1
  108. package/dist/store-redis.d.ts.map +1 -1
  109. package/dist/store-redis.js +11 -11
  110. package/dist/store-redis.js.map +1 -1
  111. package/dist/store-sqlite.d.ts +1 -1
  112. package/dist/store-sqlite.d.ts.map +1 -1
  113. package/dist/store-sqlite.js +166 -32
  114. package/dist/store-sqlite.js.map +1 -1
  115. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  116. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  117. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
  118. package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
  119. package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
  120. package/dist/wire-DjfHOvdW.js.map +1 -0
  121. package/docs/01-quickstart.mdx +2 -2
  122. package/docs/actors/01-introduction.mdx +4 -0
  123. package/docs/actors/04-routes.mdx +14 -1
  124. package/docs/concepts/01-contracts.mdx +95 -0
  125. package/docs/concepts/04-state.mdx +3 -3
  126. package/docs/guides/01-timers.mdx +22 -14
  127. package/docs/guides/02-cancellation.mdx +1 -1
  128. package/docs/guides/03-react.mdx +268 -328
  129. package/docs/guides/04-local-first.mdx +32 -73
  130. package/docs/guides/05-production.mdx +40 -27
  131. package/docs/guides/06-ai-agents.mdx +680 -580
  132. package/docs/guides/09-presence.mdx +3 -7
  133. package/docs/guides/10-transports.mdx +5 -5
  134. package/docs/index.mdx +10 -18
  135. package/docs/reference/01-api.mdx +662 -195
  136. package/docs/reference/02-errors.mdx +2 -1
  137. package/examples/playground/DEPLOY.md +52 -6
  138. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  139. package/examples/playground/app/a0/[appId]/page.tsx +48 -0
  140. package/examples/playground/app/a0/a0.css +818 -0
  141. package/examples/playground/app/a0/app-list.tsx +211 -0
  142. package/examples/playground/app/a0/app-view.tsx +55 -0
  143. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
  145. package/examples/playground/app/a0/apps.test.ts +116 -0
  146. package/examples/playground/app/a0/apps.ts +87 -0
  147. package/examples/playground/app/a0/chat.tsx +364 -0
  148. package/examples/playground/app/a0/columns.tsx +109 -0
  149. package/examples/playground/app/a0/context-menu.tsx +116 -0
  150. package/examples/playground/app/a0/cookies.ts +46 -0
  151. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  152. package/examples/playground/app/a0/crashes/route.ts +3 -0
  153. package/examples/playground/app/a0/crashes.test.ts +33 -0
  154. package/examples/playground/app/a0/crashes.ts +28 -0
  155. package/examples/playground/app/a0/devtools.tsx +164 -0
  156. package/examples/playground/app/a0/events/route.ts +6 -0
  157. package/examples/playground/app/a0/flags.ts +30 -0
  158. package/examples/playground/app/a0/layout.tsx +38 -0
  159. package/examples/playground/app/a0/list/route.ts +10 -0
  160. package/examples/playground/app/a0/model.test.ts +56 -0
  161. package/examples/playground/app/a0/model.ts +58 -0
  162. package/examples/playground/app/a0/page.tsx +14 -0
  163. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  164. package/examples/playground/app/a0/preview.test.ts +53 -0
  165. package/examples/playground/app/a0/preview.ts +26 -0
  166. package/examples/playground/app/a0/sandbox-log.ts +69 -0
  167. package/examples/playground/app/a0/sandbox.ts +191 -0
  168. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  169. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  170. package/examples/playground/app/a0/server.ts +158 -0
  171. package/examples/playground/app/a0/session.ts +47 -0
  172. package/examples/playground/app/a0/shell.tsx +150 -0
  173. package/examples/playground/app/a0/short/events/route.ts +9 -0
  174. package/examples/playground/app/a0/splitter.tsx +120 -0
  175. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  176. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  177. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  178. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  179. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  180. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  181. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  182. package/examples/playground/app/agent/events/route.ts +5 -2
  183. package/examples/playground/app/agent/ingress.test.ts +98 -0
  184. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  185. package/examples/playground/app/agent/model-input.tsx +115 -0
  186. package/examples/playground/app/agent/model.test.ts +73 -0
  187. package/examples/playground/app/agent/model.ts +58 -26
  188. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  189. package/examples/playground/app/agent/page.tsx +4 -4
  190. package/examples/playground/app/agent/server.ts +15 -14
  191. package/examples/playground/app/agent/session.ts +5 -6
  192. package/examples/playground/app/agent/telemetry.test.ts +168 -0
  193. package/examples/playground/app/agent/telemetry.ts +30 -0
  194. package/examples/playground/app/agent/trace.test.ts +23 -0
  195. package/examples/playground/app/agent/trace.ts +18 -0
  196. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
  197. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  198. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  199. package/examples/playground/app/canvas/model.ts +53 -53
  200. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  201. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  202. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  203. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  204. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  205. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  206. package/examples/playground/app/chat/agent/route.ts +9 -0
  207. package/examples/playground/app/chat/model.test.ts +46 -119
  208. package/examples/playground/app/chat/model.ts +82 -128
  209. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  210. package/examples/playground/app/chat/server.test.ts +108 -0
  211. package/examples/playground/app/chat/server.ts +69 -73
  212. package/examples/playground/app/components/activity-feed.tsx +30 -1
  213. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  214. package/examples/playground/app/components/demo-icons.tsx +89 -0
  215. package/examples/playground/app/components/demos.tsx +33 -0
  216. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  217. package/examples/playground/app/components/fast-link.tsx +55 -0
  218. package/examples/playground/app/components/site-header.tsx +96 -0
  219. package/examples/playground/app/counter/counter-client.tsx +3 -3
  220. package/examples/playground/app/counter/model.test.ts +3 -3
  221. package/examples/playground/app/counter/model.ts +11 -21
  222. package/examples/playground/app/counter/page.tsx +2 -15
  223. package/examples/playground/app/counter/session.ts +2 -2
  224. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  225. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  226. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  227. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  228. package/examples/playground/app/documents/model.ts +27 -27
  229. package/examples/playground/app/globals.css +420 -207
  230. package/examples/playground/app/initial-state.test.tsx +54 -0
  231. package/examples/playground/app/layout.tsx +7 -23
  232. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  233. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  234. package/examples/playground/app/orders/model.ts +49 -49
  235. package/examples/playground/app/page.tsx +13 -102
  236. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  237. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  238. package/examples/playground/app/recovery/model.ts +30 -30
  239. package/examples/playground/instrumentation.ts +5 -0
  240. package/examples/playground/package.json +10 -2
  241. package/examples/playground/scripts/a0-template.ts +77 -0
  242. package/examples/playground/vercel.json +18 -0
  243. package/package.json +3 -1
  244. package/src/actor-client.ts +16 -4
  245. package/src/actor-react.ts +25 -11
  246. package/src/actor-shared.ts +21 -6
  247. package/src/actor.ts +76 -31
  248. package/src/ai-agent-actions.ts +219 -0
  249. package/src/ai-agent.ts +2343 -0
  250. package/src/ai-app-state.ts +91 -0
  251. package/src/ai-client-state.ts +39 -2
  252. package/src/ai-client.ts +62 -0
  253. package/src/ai-context-schema.ts +25 -5
  254. package/src/ai-context.ts +90 -40
  255. package/src/ai-control-server.ts +94 -55
  256. package/src/ai-control.ts +2 -0
  257. package/src/ai-coordinator.ts +23 -22
  258. package/src/ai-message-base.ts +209 -0
  259. package/src/ai-message-projection.ts +382 -58
  260. package/src/ai-progress-batches.ts +26 -0
  261. package/src/ai-projector.ts +95 -14
  262. package/src/ai-react.ts +146 -0
  263. package/src/ai-server.browser.ts +2 -2
  264. package/src/ai-server.ts +20 -1951
  265. package/src/ai-stored-state.ts +48 -163
  266. package/src/ai.ts +343 -276
  267. package/src/bootstrap-stream.ts +72 -0
  268. package/src/bounded-cache.ts +55 -0
  269. package/src/client-render-session.ts +49 -0
  270. package/src/client.ts +688 -151
  271. package/src/contract.ts +92 -42
  272. package/src/deterministic-id.ts +37 -0
  273. package/src/equal-json.ts +32 -0
  274. package/src/errors.ts +13 -1
  275. package/src/index.ts +5 -1
  276. package/src/initial-snapshot.ts +39 -0
  277. package/src/internal.ts +45 -0
  278. package/src/postgres-notification-scope.ts +14 -3
  279. package/src/react.ts +171 -206
  280. package/src/redis-http-reply.ts +256 -0
  281. package/src/redis-http-stream.ts +73 -0
  282. package/src/redis-http-subscriptions.ts +13 -5
  283. package/src/reducer-runtime.ts +52 -0
  284. package/src/reducer.ts +92 -10
  285. package/src/scheduler-qstash.ts +49 -20
  286. package/src/scheduler-task.ts +34 -2
  287. package/src/scheduler-vercel.ts +32 -84
  288. package/src/server-fetch.ts +139 -35
  289. package/src/server.ts +957 -237
  290. package/src/session-clear.ts +23 -0
  291. package/src/session-socket.ts +154 -49
  292. package/src/sse.ts +71 -4
  293. package/src/store-memory.ts +220 -78
  294. package/src/store-polling.ts +18 -4
  295. package/src/store-postgres.ts +461 -114
  296. package/src/store-redis-core.ts +1260 -434
  297. package/src/store-redis-http.ts +18 -2
  298. package/src/store-redis-notify.ts +260 -85
  299. package/src/store-redis.ts +19 -9
  300. package/src/store-sqlite.ts +259 -45
  301. package/src/store.ts +46 -1
  302. package/src/stream-buffer.ts +43 -0
  303. package/src/telemetry.ts +13 -1
  304. package/src/wire.ts +241 -13
  305. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  306. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  307. package/dist/ai-CrLYwNEx.js.map +0 -1
  308. package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
  309. package/dist/client-BYzHjkwU.js.map +0 -1
  310. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  311. package/dist/contract-CKRg_E4q.js.map +0 -1
  312. package/dist/errors-DCk6ch5n.js.map +0 -1
  313. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  314. package/dist/internal-Dq2qYxou.js.map +0 -1
  315. package/dist/react.d.ts.map +0 -1
  316. package/dist/reducer-DEMjEY_O.js +0 -29
  317. package/dist/reducer-DEMjEY_O.js.map +0 -1
  318. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  319. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  320. package/dist/server-BD5ckxZb.js.map +0 -1
  321. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  322. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  323. package/dist/store-polling-CmxUbV93.js.map +0 -1
  324. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  325. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  326. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  327. package/dist/validate-XKT4FSNn.js +0 -30
  328. package/dist/validate-XKT4FSNn.js.map +0 -1
  329. package/dist/wire-BO5wWCb1.js.map +0 -1
  330. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  331. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  332. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  333. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  334. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
@@ -20,32 +20,15 @@ AI_GATEWAY_API_KEY=your_api_key
20
20
 
21
21
  ## Define the agent
22
22
 
23
- `agent()` defines an isomorphic event contract, normalized `AIStoredState`,
24
- and its `AIState` view. The same reducer types server snapshots, hydration,
25
- and optimistic client updates.
23
+ `agent()` is the whole server: the built-in AI protocol, the AI SDK runner, and
24
+ A2's ordinary store, scheduler, telemetry, and handler machinery, assembled as
25
+ one A2 server.
26
26
 
27
- ```ts assistant.ts
28
- import { agent } from 'experimental-a2/ai'
27
+ ```ts server/assistant.ts
28
+ import { agent } from 'experimental-a2/ai/server'
29
29
 
30
30
  export const assistant = agent({
31
31
  name: 'support-agent',
32
- })
33
- ```
34
-
35
- Keep this module isomorphic. Model providers, secrets, tools, and storage belong
36
- in the server module.
37
-
38
- ## Connect the model
39
-
40
- `createAgentServer()` adds the AI SDK runner and A2's ordinary store,
41
- scheduler, telemetry, and handler machinery:
42
-
43
- ```ts server/assistant.ts
44
- import { createAgentServer } from 'experimental-a2/ai/server'
45
- import { assistant } from '../assistant'
46
-
47
- export const assistantServer = createAgentServer({
48
- agent: assistant,
49
32
  model: 'openai/gpt-5.6-terra',
50
33
  instructions: 'Help the customer. Be concise.',
51
34
  generation: {
@@ -55,6 +38,11 @@ export const assistantServer = createAgentServer({
55
38
  })
56
39
  ```
57
40
 
41
+ `name` is the agent's identity: it prefixes storage keys and queue messages.
42
+ `experimental-a2/ai/server` is server-only, and a browser bundle that imports it
43
+ fails loudly. Browser code reaches the agent through its route and a type-only
44
+ import, shown below.
45
+
58
46
  A string model id uses the AI SDK's global provider, which is AI Gateway by
59
47
  default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
60
48
  model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
@@ -72,6 +60,12 @@ settings, including `toolsContext`, when calling the AI SDK. A local tool
72
60
  reads its durable execution context later, from the handler that runs it. Model-step
73
61
  timeouts are supported; tool-execution timeouts are not.
74
62
 
63
+ `generation` also accepts a sync or async resolver with the same context as
64
+ `model`, called once per attempt before compaction and model dispatch. Read
65
+ application state with `session.state()`, which defaults to the captured
66
+ generation snapshot. See the
67
+ [API reference](/reference/api#agentoptions).
68
+
75
69
  For AI Gateway models, the default generator sets
76
70
  `generation.providerOptions.gateway.caching` to `'auto'`. A2 merges this
77
71
  default with your provider options, with caller-supplied values taking
@@ -91,102 +85,35 @@ Gateway's provider or guarantee a cache hit.
91
85
  See [Gateway automatic caching](https://vercel.com/docs/ai-gateway/models-and-providers/automatic-caching)
92
86
  for provider support and cache-write costs.
93
87
 
94
- ### Store assistant metadata
95
-
96
- Extend the agent's `UIMessage` type to keep generation status and usage on the
97
- assistant message:
98
-
99
- ```ts metadata-assistant.ts
100
- import type { LanguageModelUsage, UIMessage } from 'ai'
101
- import { agent } from 'experimental-a2/ai'
102
-
103
- export type MetadataAssistantMessage = UIMessage<{
104
- status: 'writing' | 'complete'
105
- usage?: LanguageModelUsage
106
- }>
107
-
108
- export const metadataAssistant = agent<MetadataAssistantMessage>({
109
- name: 'metadata-assistant',
110
- })
111
- ```
112
-
113
- Map the AI SDK's standard stream parts to that metadata type:
114
-
115
- ```ts server/metadata-assistant.ts
116
- import { createAgentServer } from 'experimental-a2/ai/server'
117
- import { metadataAssistant } from '../metadata-assistant'
118
-
119
- export const metadataAssistantServer = createAgentServer({
120
- agent: metadataAssistant,
121
- model: 'openai/gpt-5.6-terra',
122
- messageMetadata: ({ part }) => {
123
- if (part.type === 'start') return { status: 'writing' as const }
124
- if (part.type === 'finish') {
125
- return { status: 'complete' as const, usage: part.totalUsage }
126
- }
127
- return undefined
128
- },
129
- })
130
- ```
131
-
132
- The default generator passes `messageMetadata` to the AI SDK's
133
- `toUIMessageStream()`. The callback receives the generation context and its
134
- current `TextStreamPart`. Its return type follows the metadata type in the
135
- agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
136
- the event log with the rest of the stream. A2 shallow-merges those values onto
137
- the projected assistant message, so the example exposes `writing` immediately
138
- and keeps final token usage after completion. The callback is synchronous.
139
-
140
- `experimental-a2/ai/server` is server-only. The isomorphic
141
- `experimental-a2/ai` entry point contains the contract, reducer, schemas,
142
- `handlerContext()`, and pure inputs; it never imports a model provider or
143
- backend.
144
-
145
- ## Expose the event stream
88
+ ## Expose the route
146
89
 
147
90
  One HTTP route gives the browser a read and write path. `GET` streams events;
148
91
  `POST` accepts optimistic pushes:
149
92
 
150
- ```ts app/api/agent-events/route.ts
151
- import { assistantServer } from '@/server/assistant'
93
+ ```ts app/api/agent/route.ts
94
+ import { assistant } from '@/server/assistant'
152
95
 
153
- export const GET = assistantServer.fetch
154
- export const POST = assistantServer.fetch
96
+ export const GET = assistant.fetch
97
+ export const POST = assistant.fetch
155
98
  ```
156
99
 
157
- The route never calls the model directly. The browser appends
158
- `ai.control.requested` commands. The controller admits inputs and schedules
100
+ The route never calls the model directly. The browser pushes user messages and
101
+ controls as `ai.control.requested` commands, and `fetch` rejects every
102
+ server-authored event with `400`. The controller admits inputs and schedules
159
103
  `ai.generation.requested`; workers run the AI SDK and append progress to the
160
104
  same log.
161
105
 
162
- ## Bind the reducer to React
106
+ Authentication stays in your route. Here's where you'd do auth, or any other
107
+ checks, then hand the request to `assistant.fetch(request, { authorize })` for
108
+ per-operation policy.
163
109
 
164
- Create the typed provider and hook in a client module:
110
+ ## Render the conversation
165
111
 
166
- ```tsx app/agent/session.ts
167
- 'use client'
168
- import { createClient } from 'experimental-a2/client'
169
- import { createReact } from 'experimental-a2/react'
170
- import { assistant } from '@/assistant'
171
-
172
- export const assistantClient = createClient({
173
- reducer: assistant.reducer,
174
- api: '/api/agent-events',
175
- })
176
-
177
- export const { SessionProvider, useSession } = createReact({
178
- client: assistantClient,
179
- })
180
- ```
181
-
182
- Fold the initial state on the server, then let the provider resume the event
183
- stream from that exact index:
112
+ Fold the initial state on the server and hand it to a client component:
184
113
 
185
114
  ```tsx app/agent/[sessionId]/page.tsx
186
- import { assistant } from '@/assistant'
187
- import { assistantServer } from '@/server/assistant'
188
- import { AgentClient } from './agent-client'
189
- import { SessionProvider } from '../session'
115
+ import { assistant } from '@/server/assistant'
116
+ import { Chat } from './chat'
190
117
 
191
118
  export default async function AgentPage({
192
119
  params,
@@ -194,96 +121,53 @@ export default async function AgentPage({
194
121
  params: Promise<{ sessionId: string }>
195
122
  }) {
196
123
  const { sessionId } = await params
197
- const session = assistantServer.session(sessionId)
198
- const { state, index } = await session.state(assistant.reducer)
199
-
200
- return (
201
- <SessionProvider
202
- sessionId={sessionId}
203
- initialState={state}
204
- initialIndex={index}
205
- >
206
- <AgentClient />
207
- </SessionProvider>
208
- )
124
+ const initial = await assistant.session(sessionId).state()
125
+ return <Chat sessionId={sessionId} initial={initial} />
209
126
  }
210
127
  ```
211
128
 
212
129
  An empty session folds to the initial `AIState`. Its log is created lazily when
213
130
  the first message is pushed.
214
131
 
215
- Start the session from the previous route, but wait for its durable append
216
- before navigating. The destination server render then sees the message in its
217
- folded state instead of racing the `POST`:
218
-
219
- ```tsx app/agent/new-agent-session.tsx
220
- 'use client'
221
- import type { UIMessage } from 'ai'
222
- import { inputs } from 'experimental-a2/ai'
223
- import { assistantClient } from './session'
224
-
225
- export async function openAgent(
226
- sessionId: string,
227
- message: UIMessage,
228
- router: { push(href: string): void },
229
- ): Promise<void> {
230
- await assistantClient.session(sessionId).push(...inputs.message(message))
231
- router.push(`/agent/${sessionId}`)
232
- }
233
- ```
234
-
235
- The draft clears immediately while the append is in flight and can be restored
236
- if it fails. Once navigation begins, SSR already has folded state at a durable
237
- frontier. When the destination provider mounts, it resolves the same live
238
- session and reconciles that server render without replacing the object. Idle
239
- session objects expire after five minutes by default; configure `gcTime` on
240
- `createClient` when a different lifetime fits the app.
241
-
242
- ## Send messages from the client
243
-
244
- Build the message inline and pass the resulting events straight to `push()`:
132
+ `useAgent()` keeps that state live and returns the actions a chat needs:
245
133
 
246
- ```tsx app/agent/[sessionId]/agent-client.tsx
134
+ ```tsx app/agent/[sessionId]/chat.tsx
247
135
  'use client'
248
136
  import type { FormEvent } from 'react'
249
137
  import { useState } from 'react'
250
- import { inputs } from 'experimental-a2/ai'
251
- import { useSession } from '../session'
138
+ import type { StateSnapshot } from 'experimental-a2'
139
+ import type { AIState } from 'experimental-a2/ai'
140
+ import { useAgent } from 'experimental-a2/ai/react'
141
+ import type { assistant } from '@/server/assistant'
142
+
143
+ const indicatorLabel = {
144
+ starting: 'Starting…',
145
+ thinking: 'Thinking…',
146
+ compacting: 'Compacting…',
147
+ }
252
148
 
253
- export function AgentClient() {
254
- const { state, push } = useSession()
149
+ export function Chat({
150
+ sessionId,
151
+ initial,
152
+ }: {
153
+ sessionId: string
154
+ initial: StateSnapshot<AIState>
155
+ }) {
156
+ const { messages, status, indicator, sendMessage } =
157
+ useAgent<typeof assistant>({ api: '/api/agent', id: sessionId, initial })
255
158
  const [draft, setDraft] = useState('')
256
159
  const [error, setError] = useState<string | null>(null)
257
160
 
258
- const canSend = state.status !== 'closed'
259
- const lastMessage = state.messages.at(-1)
260
- const lastText =
261
- lastMessage?.parts
262
- .flatMap((part) => (part.type === 'text' ? [part.text] : []))
263
- .join('\n') ?? ''
264
- const showThinking =
265
- state.status === 'generating' &&
266
- (lastMessage?.role !== 'assistant' || lastText.length === 0)
267
-
268
161
  async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
269
162
  event.preventDefault()
270
- const previousDraft = draft
271
- const text = previousDraft.trim()
272
- if (!text || !canSend) return
273
-
163
+ const text = draft.trim()
164
+ if (!text) return
274
165
  setDraft('')
275
166
  setError(null)
276
-
277
167
  try {
278
- await push(
279
- ...inputs.message({
280
- id: crypto.randomUUID(),
281
- role: 'user',
282
- parts: [{ type: 'text', text }],
283
- }),
284
- )
168
+ await sendMessage(text)
285
169
  } catch (cause) {
286
- setDraft(previousDraft)
170
+ setDraft(text)
287
171
  setError(cause instanceof Error ? cause.message : String(cause))
288
172
  }
289
173
  }
@@ -291,25 +175,16 @@ export function AgentClient() {
291
175
  return (
292
176
  <main>
293
177
  <section aria-label="Conversation">
294
- {state.messages.map((message) => {
295
- const text = message.parts
296
- .flatMap((part) => (part.type === 'text' ? [part.text] : []))
297
- .join('\n')
298
- if (!text) return null
299
-
300
- return (
301
- <article key={message.id}>
302
- <strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
303
- <p>{text}</p>
304
- </article>
305
- )
306
- })}
307
-
308
- {showThinking ? (
309
- <article aria-live="polite">
310
- <strong>Agent</strong>
311
- <p>Thinking…</p>
178
+ {messages.map((message) => (
179
+ <article key={message.id}>
180
+ <strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
181
+ {message.parts.map((part, index) =>
182
+ part.type === 'text' ? <p key={index}>{part.text}</p> : null,
183
+ )}
312
184
  </article>
185
+ ))}
186
+ {indicator ? (
187
+ <p role="status">{indicatorLabel[indicator]}</p>
313
188
  ) : null}
314
189
  </section>
315
190
 
@@ -319,7 +194,7 @@ export function AgentClient() {
319
194
  value={draft}
320
195
  onChange={(event) => setDraft(event.currentTarget.value)}
321
196
  />
322
- <button disabled={!draft.trim() || !canSend}>Send</button>
197
+ <button disabled={!draft.trim() || status === 'closed'}>Send</button>
323
198
  </form>
324
199
 
325
200
  {error ? <p role="alert">{error}</p> : null}
@@ -328,14 +203,29 @@ export function AgentClient() {
328
203
  }
329
204
  ```
330
205
 
331
- `push()` persists the command. Its receipt confirms admission to the inbox.
332
- `state.inbox.items` contains pending inputs; `state.messages` contains the
333
- admitted conversation. If persistence fails, the component restores the draft.
334
-
335
- The form gives Enter its normal submit behavior. The thinking row is also
336
- derived from `AIState`: it appears with the Agent label when the server's
337
- generation request folds, then the first visible assistant content replaces
338
- it. Empty stream-start messages never create a blank conversation row.
206
+ The `import type` of the server module is erased at build time, so neither the
207
+ server nor a contract enters the client bundle. The AI protocol and its reducer
208
+ are library code; the type argument only carries the agent's message types. The hook keeps one client per `api` for the page's lifetime,
209
+ and every component that calls `useAgent()` with the same `api` and `id` shares
210
+ one live session. That client reads `globalThis.fetch` on each request; pass
211
+ `client` instead of `api` for a custom `fetch`, headers, or `gcTime`. Actions are
212
+ rebuilt on every render, so do not rely on their identity.
213
+
214
+ The result is the ordinary session result (`state`, `index`, `connection`,
215
+ `push`, `events`, and history) plus actions, `messages` (the same array as
216
+ `state.messages`), `status`, and `indicator`. `indicator` names what the UI
217
+ shows while nothing new is visible yet: `starting` for an optimistic turn
218
+ awaiting acceptance, `compacting` while a summary runs, `thinking` while the
219
+ current model step has produced no visible part, and `null` otherwise. Reasoning
220
+ and step boundaries do not count as visible output, so the indicator returns
221
+ between tool steps.
222
+
223
+ `sendMessage()` turns a string into one text part with a fresh message ID, or
224
+ takes a complete `UIMessage`. Every action returns the same result as `push()`:
225
+ it resolves to the persisted command events once they are stored, and its
226
+ `.confirmed` promise resolves when the stream delivers them back. The draft
227
+ clears immediately and returns if the push fails. `state.inbox.items`
228
+ contains pending inputs; `state.messages` contains the admitted conversation.
339
229
 
340
230
  Users can send another message while a response is active. The message enters
341
231
  the durable log immediately, but its turn waits until the active assistant
@@ -343,22 +233,8 @@ response, including every tool step and approval, reaches a terminal event.
343
233
  Inbox order defaults to arrival order and can be edited explicitly.
344
234
 
345
235
  Pass `{ generate: false }` when a user message should update the conversation
346
- without starting a model turn:
347
-
348
- ```ts app/agent/passive-message.ts
349
- import type { UIMessage } from 'ai'
350
- import { inputs } from 'experimental-a2/ai'
351
- import { assistantClient } from './session'
352
-
353
- export async function recordPassiveMessage(
354
- sessionId: string,
355
- message: UIMessage,
356
- ): Promise<void> {
357
- await assistantClient
358
- .session(sessionId)
359
- .push(...inputs.message(message, { generate: false }))
360
- }
361
- ```
236
+ without starting a model turn, as in
237
+ `sendMessage('Order #4411 shipped this morning.', { generate: false })`.
362
238
 
363
239
  The passive user message appears in `AIState.messages` when admitted. A later user
364
240
  message with the default generation behavior includes passive user messages
@@ -367,7 +243,92 @@ outside its prompt and wait for the next generating message. This ordering
367
243
  supports ambient agents without letting new background context leak into an
368
244
  already queued turn.
369
245
 
370
- ## What happens after `push()`
246
+ ## Start a session from another page
247
+
248
+ Wait for the durable append before navigating, so the destination's server
249
+ render sees the message in its folded state instead of racing the `POST`.
250
+ `createAgentClient()` is the same contract-free client outside React:
251
+
252
+ ```ts app/agent/client.ts
253
+ 'use client'
254
+ import { createAgentClient } from 'experimental-a2/ai/client'
255
+ import type { assistant } from '@/server/assistant'
256
+
257
+ export const assistantClient = createAgentClient<typeof assistant>({
258
+ api: '/api/agent',
259
+ })
260
+ ```
261
+
262
+ ```ts app/agent/open-agent.ts
263
+ 'use client'
264
+ import { inputs } from 'experimental-a2/ai'
265
+ import { assistantClient } from './client'
266
+
267
+ export async function openAgent({
268
+ sessionId,
269
+ text,
270
+ router,
271
+ }: {
272
+ sessionId: string
273
+ text: string
274
+ router: { push(href: string): void }
275
+ }): Promise<void> {
276
+ await assistantClient.session(sessionId).push(
277
+ ...inputs.message({
278
+ id: crypto.randomUUID(),
279
+ role: 'user',
280
+ parts: [{ type: 'text', text }],
281
+ }),
282
+ )
283
+ router.push(`/agent/${sessionId}`)
284
+ }
285
+ ```
286
+
287
+ Pass the same client to the destination's hook,
288
+ `useAgent<typeof assistant>({ client: assistantClient, id: sessionId, initial })`,
289
+ so it resolves the live session the push created and reconciles the server
290
+ render without replacing it. Idle session objects expire after five minutes by
291
+ default; configure `gcTime` on `createAgentClient` when a different lifetime
292
+ fits the app.
293
+
294
+ ## Choose the model
295
+
296
+ `model` takes a model, or `ModelSettings` to choose its reasoning level and
297
+ speed with it:
298
+
299
+ ```ts server/fast-assistant.ts
300
+ import { agent } from 'experimental-a2/ai/server'
301
+
302
+ export const fastAssistant = agent({
303
+ name: 'fast-support',
304
+ model: { model: 'openai/gpt-5.6-terra', reasoning: 'low', speed: 'fast' },
305
+ })
306
+ ```
307
+
308
+ A resolver returns the same shape for each generation, so the choice can come
309
+ from anything it reads, such as the conversation so far:
310
+
311
+ ```ts server/routed-assistant.ts
312
+ import { agent } from 'experimental-a2/ai/server'
313
+
314
+ export const routedAssistant = agent({
315
+ name: 'routed-support',
316
+ model: ({ messages }) =>
317
+ messages.length > 20
318
+ ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
319
+ : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
320
+ })
321
+ ```
322
+
323
+ `reasoning` becomes the AI SDK's `reasoning` call option. `speed: 'fast'` sets
324
+ `providerOptions.gateway.speed` to prefer AI Gateway's faster tier when the
325
+ model offers one; `'standard'` removes a configured speed. Both override the
326
+ same settings in `generation`. `reasoningLevels` from `experimental-a2/ai`
327
+ lists the levels, for building a picker. To choose from your app's state, such
328
+ as the user's plan or a picker's selection, see
329
+ [Add your own events and state](#add-your-own-events-and-state).
330
+
331
+ ## What happens after a send
371
332
 
372
333
  One generating user interaction becomes a durable sequence:
373
334
 
@@ -394,15 +355,20 @@ automatic tool can finish while the model stream is still open. The join waits
394
355
  for both step closure and every required terminal result.
395
356
 
396
357
  The SSE connection delivers the server events to the same reducer. Progress
397
- stores every AI SDK chunk once. The reducer and the exported
358
+ stores streamed output once. Within a batch, adjacent text, reasoning, and
359
+ tool-input deltas of one part are joined into a single chunk. The reducer and the exported
398
360
  `deriveUIMessages()` apply those chunks synchronously to produce the current
399
361
  `UIMessage`; cumulative message snapshots are not duplicated in the log.
400
362
 
401
- `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
402
- `activeResponseMessageId`, `activeProjection`, `responseOwners`,
403
- `terminalRequestIds`, `terminalGenerations`, `pendingApprovals`,
404
- `pendingInputs`, `tools`, `compaction`, per-generation `usage`, and the last
405
- generation `error`.
363
+ `AIState` exposes `messages`, `status`, `inbox`, `active`,
364
+ `pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
365
+ `usage`, the latest command `receipt`, and the last generation `error`. It also
366
+ carries the protocol's ownership bookkeeping: `activeGeneration`,
367
+ `activeRequestId`, `activeResponseMessageId`, `activeProjection`,
368
+ `responseOwners`, `terminalRequestIds`, and `terminalGenerations`. The hook's
369
+ actions read these fields for you; lower-level `inputs` builders need them
370
+ explicitly.
371
+
406
372
  `activeProjection` is the temporary indexed generation frontier used for exact
407
373
  interruption. It remains available after a generation step completes while the
408
374
  response waits on tool, approval, or input barriers, and after a generation
@@ -412,10 +378,9 @@ replaces it.
412
378
 
413
379
  `activeRequestId` identifies the server-authorized generation request. It
414
380
  prevents a delayed request or recovered attempt from taking ownership from the
415
- current response before its generation starts.
416
-
417
- `activeResponseMessageId` identifies that request's response before
418
- `activeGeneration` exists. It is `null` after the generation starts.
381
+ current response before its generation starts. `activeResponseMessageId`
382
+ identifies that request's response before `activeGeneration` exists. It is
383
+ `null` after the generation starts.
419
384
 
420
385
  `responseOwners` keeps the latest generation ID, request ID, and attempt for each response
421
386
  message. Late lifecycle events from a superseded generation stay in raw
@@ -429,61 +394,12 @@ IDs as opaque. An assistant response uses its first model-request event's ID. Ge
429
394
  Ownership comes from these fields and the generation's `requestId` and `attempt`,
430
395
  so ID strings never embed the preceding tool loop.
431
396
 
432
- Approval and application-input events carry both `requestId` and `generationId`.
433
- Copy both from the pending approval, pending input, or active generation when
434
- sending a response. `activeRequestReason` and `activeRequestSourceGenerationId`
435
- describe the current request independently from the message projection.
436
-
437
397
  Generation requests share one durable lane, so model calls do not overlap.
438
398
  The queued-turn policy is the stronger ordering rule: a later user message is
439
399
  not scheduled until the current response's complete tool loop finishes. Lane
440
400
  FIFO alone would not prevent a newly appended user turn from overtaking a tool
441
401
  continuation that has not been appended yet.
442
402
 
443
- ## The built-in inputs
444
-
445
- `inputs` contains pure, isomorphic builders for protocol interactions:
446
-
447
- | Input | Events |
448
- | --- | --- |
449
- | `inputs.message(message, { generate? })` | queues input; `false` queues passive context |
450
- | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
451
- | `inputs.approval(response)` | requests a decision on a pending approval |
452
- | `inputs.input(response)` | records an application input response fact |
453
- | `inputs.requestInput(request)` | records a trusted server request for application input |
454
- | `inputs.retry(options)` | requests a fresh attempt for a failed response |
455
- | `inputs.stop({ turnId })` | ends the observed turn and advances |
456
- | `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
457
- | `inputs.queue.edit({ message })` | edits a pending input |
458
- | `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
459
- | `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
460
- | `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
461
- | `inputs.pause({ when })` | pauses now or after the active turn |
462
- | `inputs.resume()` | resumes retained work and inbox admission |
463
- | `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
464
- | `inputs.interrupt(options)` | addresses a specific generation or request |
465
-
466
- Builders return `ai.control.requested` commands. Message, approval, input, and
467
- retry builders identify a stable interaction. Other controls use the event ID
468
- assigned by `push()` or `append()`; transport retries preserve that ID.
469
- Generation scheduling considers only user-role `ai.message.created` facts.
470
- Assistant and system message facts remain context regardless of the `generate`
471
- field, and model output is recorded as generation progress rather than a new
472
- message fact, so an assistant reply cannot recursively schedule another turn.
473
- Browser `push()` accepts user messages, approval and input responses,
474
- interruptions, and explicit retries. `inputs.requestInput()` and non-user
475
- messages built with `inputs.seed()` are for trusted server appends. Browser
476
- inputs never append server scheduling, seeded non-user messages, or lifecycle
477
- events.
478
- Approval and input request/response payloads carry the active `generationId`;
479
- clients copy it from the pending request so a stale interaction cannot satisfy
480
- a later model step.
481
-
482
- `inputs` deliberately has no session lifecycle methods. The A2 log begins with
483
- the first append. Apps that use explicit `ai.session.created` or
484
- `ai.session.closed` lifecycle events append those trusted server facts
485
- directly.
486
-
487
403
  ## Add tools and approval
488
404
 
489
405
  Tools are ordinary AI SDK `tool()` definitions. A2 passes them through to
@@ -493,8 +409,7 @@ belongs in `generation`, beside the other per-step settings:
493
409
  ```ts server/with-tools.ts
494
410
  import { tool } from 'ai'
495
411
  import { z } from 'zod'
496
- import { createAgentServer } from 'experimental-a2/ai/server'
497
- import { assistant } from '../assistant'
412
+ import { agent } from 'experimental-a2/ai/server'
498
413
 
499
414
  const tools = {
500
415
  closeTicket: tool({
@@ -508,8 +423,8 @@ const tools = {
508
423
  }),
509
424
  }
510
425
 
511
- export const assistantServerWithTools = createAgentServer({
512
- agent: assistant,
426
+ export const toolsAssistant = agent({
427
+ name: 'support-tools',
513
428
  model: 'openai/gpt-5.6-terra',
514
429
  tools,
515
430
  generation: {
@@ -523,15 +438,15 @@ export const assistantServerWithTools = createAgentServer({
523
438
  The AI SDK infers the tool input and output from its schemas and
524
439
  `execute`. A2 additionally runs each local `execute` inside an ambient
525
440
  scope carrying the current durable handler attempt. Read it with
526
- `handlerContext(agent)` from `experimental-a2/ai`: it returns the same
527
- typed context bag an event handler receives, with `event`, `attempt`,
528
- `session`, and `signal`. Tools execute an `ai.tool.execution.requested`
529
- event after authorization. Its payload contains the admitted `call`, its
530
- `generation`, and its turn and execution version. The agent argument carries the types; A2 verifies it against the
531
- server executing the tool and throws when a tool written for one agent
532
- runs under another, or when `handlerContext()` is called outside a tool
533
- execution. The scope survives awaited helpers and async iteration, so
534
- any code below `execute` can read it.
441
+ `handlerContext()` from `experimental-a2/ai`: it returns the same context an
442
+ event handler receives, with `event`, `attempt`, `session`, and `signal`. Tools
443
+ execute an `ai.tool.execution.requested` event after authorization. Its payload
444
+ contains the admitted `call`, its `generation`, and its turn and execution
445
+ version. `handlerContext()` throws when called outside a tool execution. The
446
+ scope survives awaited helpers and async iteration, so any code below `execute`
447
+ can read it. Because the call takes no argument, a tool can live inside the
448
+ `agent()` call that serves it. Pass a shared `agentContract()` to type
449
+ application events on `ctx.session`; A2 checks it against the executing agent.
535
450
 
536
451
  `generation.toolApproval` is evaluated during the model step, before A2 has a
537
452
  durable tool handler. Static policies such as the example above are simplest.
@@ -545,16 +460,14 @@ untouched. Any model-phase hook that receives tool definitions sees
545
460
  them without local `execute` functions. The durable A2 context is
546
461
  ambient only inside `execute`.
547
462
 
548
- The agent argument supplies types, not infrastructure. The context's
549
- `session` belongs to the server executing the tool, so its `schedule()`
550
- method uses that server's scheduler. For example, this tool schedules a
551
- reminder as a new user message:
463
+ The context's `session` belongs to the agent executing the tool, so its
464
+ `schedule()` method uses that agent's scheduler. For example, this tool
465
+ schedules a reminder as a new user message:
552
466
 
553
467
  ```ts server/set-reminder.ts
554
468
  import { tool } from 'ai'
555
469
  import { handlerContext, inputs } from 'experimental-a2/ai'
556
470
  import { z } from 'zod'
557
- import { assistant } from '../assistant'
558
471
 
559
472
  export const setReminder = tool({
560
473
  description: 'Set a reminder for yourself',
@@ -563,7 +476,7 @@ export const setReminder = tool({
563
476
  reminder: z.string(),
564
477
  }),
565
478
  execute: async ({ delaySeconds, reminder }, { toolCallId }) => {
566
- const ctx = handlerContext(assistant)
479
+ const ctx = handlerContext()
567
480
  await ctx.session.schedule(
568
481
  `reminder:${toolCallId}`,
569
482
  { delay: `${delaySeconds}s` },
@@ -579,7 +492,7 @@ export const setReminder = tool({
579
492
  })
580
493
  ```
581
494
 
582
- Add `setReminder` to the server's `tools` and configure a
495
+ Add `setReminder` to the agent's `tools` and configure a
583
496
  [scheduler](/guides/production).
584
497
  The numeric schema lets the model choose any whole-second delay in the
585
498
  provider's supported range instead of selecting from a fixed application enum.
@@ -601,51 +514,29 @@ A tool that never reads `handlerContext()` is just an AI SDK tool;
601
514
  nothing else changes. To test a context-reading tool directly, wrap the
602
515
  call with `runWithHandlerContext(ctx, fn)` from
603
516
  `experimental-a2/ai/server`: inside `fn`, `handlerContext()` returns
604
- `ctx` for any agent.
517
+ `ctx`.
605
518
 
606
519
  The AI SDK emits an approval request instead of executing the tool. A2 records
607
520
  it as `ai.approval.requested`, and the reducer adds it to
608
- `state.pendingApprovals`. Respond from the same optimistic client path:
521
+ `state.pendingApprovals`. Answer it from the hook:
609
522
 
610
- ```tsx app/agent/[sessionId]/approval-controls.tsx
523
+ ```tsx app/tools/[sessionId]/approval-controls.tsx
611
524
  'use client'
612
- import { inputs } from 'experimental-a2/ai'
613
- import { useSession } from '../session'
525
+ import { useAgent } from 'experimental-a2/ai/react'
526
+ import type { toolsAssistant } from '@/server/with-tools'
614
527
 
615
- export function ApprovalControls() {
616
- const { state, push } = useSession()
528
+ export function ApprovalControls({ sessionId }: { sessionId: string }) {
529
+ const { state, approve, deny } = useAgent<typeof toolsAssistant>({
530
+ api: '/api/tools',
531
+ id: sessionId,
532
+ })
617
533
 
618
534
  return state.pendingApprovals.map((approval) => (
619
535
  <div key={approval.approvalId}>
620
536
  <span>Allow tool call {approval.toolCallId}?</span>
537
+ <button onClick={() => void approve(approval)}>Allow</button>
621
538
  <button
622
- onClick={() =>
623
- void push(
624
- ...inputs.approval({
625
- requestId: approval.requestId,
626
- messageId: approval.messageId,
627
- generationId: approval.generationId,
628
- approvalId: approval.approvalId,
629
- approved: true,
630
- }),
631
- )
632
- }
633
- >
634
- Allow
635
- </button>
636
- <button
637
- onClick={() =>
638
- void push(
639
- ...inputs.approval({
640
- requestId: approval.requestId,
641
- messageId: approval.messageId,
642
- generationId: approval.generationId,
643
- approvalId: approval.approvalId,
644
- approved: false,
645
- reason: 'Denied by the user',
646
- }),
647
- )
648
- }
539
+ onClick={() => void deny(approval, { reason: 'Denied by the user' })}
649
540
  >
650
541
  Deny
651
542
  </button>
@@ -654,10 +545,11 @@ export function ApprovalControls() {
654
545
  }
655
546
  ```
656
547
 
657
- `inputs.approval()` records only the decision and updates the approval part in
658
- the projected `UIMessage`. A built-in handler executes the approved tool or
659
- records a denied result. It never bypasses the same sibling join used by
660
- automatic tools.
548
+ `approve()` and `deny()` copy the request, message, generation, and approval
549
+ IDs from the pending approval, so a delayed click cannot authorize a later
550
+ model step. The decision updates the approval part in the projected
551
+ `UIMessage`. A built-in handler executes the approved tool or records a denied
552
+ result. It never bypasses the same sibling join used by automatic tools.
661
553
 
662
554
  Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
663
555
  execution errors, denials, and preliminary and final outputs become durable
@@ -667,7 +559,10 @@ cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
667
559
  metadata, and tool metadata fields are preserved in the projection.
668
560
 
669
561
  Independent tool handlers run concurrently as soon as their durable inputs and
670
- authorization are ready. There is no fixed tool concurrency limit. A private
562
+ authorization are ready. There is no fixed tool concurrency limit. Set
563
+ `toolLane: 'tools'` on the agent when local tools must run one at a time, for
564
+ example because they share a sandbox; every local tool execution then queues on
565
+ that lane. A private
671
566
  coordinator reducer tracks generation closure, cancellation, calls, approvals,
672
567
  and terminal results for the active response. Its retained state is bounded;
673
568
  completed responses do not accumulate in the coordinator. Each join reads a
@@ -685,51 +580,45 @@ streaming tool outputs use immediate appends. An approved provider-executed
685
580
  call can satisfy the join through its response. A provider tool that declares
686
581
  deferred-result support can wait for its provider result instead of a local
687
582
  executor. The authenticated provider callback appends that `ai.tool.result`
688
- through the trusted server session API. Browser `push()` rejects lifecycle
583
+ through the trusted server session API. Browser pushes reject lifecycle
689
584
  events, including provider results.
690
585
 
691
- `inputs.input()` provides the same durable request/response shape for
692
- application-defined input. Its value stays in raw history and
693
- `state.pendingInputs`; resolve instructions dynamically or replace `generate`
694
- when the value should become model context.
695
-
696
- Approval and application-input request/response facts carry the active
697
- `generationId`. Echo that value from the pending request so a delayed UI action
698
- cannot authorize or resume a later model step.
586
+ `state.pendingInputs` provides the same durable request/response shape for
587
+ application-defined input. Answer one with `respond(input, value)` from the
588
+ hook. Its value stays in raw history and `state.pendingInputs`; resolve
589
+ instructions dynamically or replace `generate` when the value should become
590
+ model context.
699
591
 
700
592
  ## Interrupt and retry
701
593
 
702
- Interrupt the active response from the client:
594
+ Stop the active turn, or retry a failed response, from the hook:
703
595
 
704
- ```tsx app/agent/[sessionId]/stop-button.tsx
596
+ ```tsx app/agent/[sessionId]/turn-controls.tsx
705
597
  'use client'
706
- import { inputs } from 'experimental-a2/ai'
707
- import { useSession } from '../session'
708
-
709
- export function StopButton() {
710
- const { state, push, index } = useSession()
711
- const active = state.active
712
- if (!active) return null
713
-
714
- return (
715
- <button
716
- onClick={() =>
717
- void push(
718
- ...inputs.stop({
719
- turnId: active.turnId,
720
- lastSeenIndex: index,
721
- }),
722
- )
723
- }
724
- >
725
- Stop
726
- </button>
727
- )
598
+ import { useAgent } from 'experimental-a2/ai/react'
599
+ import type { assistant } from '@/server/assistant'
600
+
601
+ export function TurnControls({ sessionId }: { sessionId: string }) {
602
+ const { state, status, stop, retry } = useAgent<typeof assistant>({
603
+ api: '/api/agent',
604
+ id: sessionId,
605
+ })
606
+
607
+ if (status === 'failed')
608
+ return <button onClick={() => void retry()}>Retry</button>
609
+ return state.active ? (
610
+ <button onClick={() => void stop()}>Stop</button>
611
+ ) : null
728
612
  }
729
613
  ```
730
614
 
731
- The accepted command ends the turn and reaches A2's cancellation channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
732
- user clicked. The reducer rewinds the indexed generation projection to that
615
+ `stop()` addresses the observed turn and sends the exact confirmed log frontier
616
+ the rendered state showed, not whatever arrived before the click was handled.
617
+ The client immediately previews Stop for that turn. While that generation's
618
+ indexed projection is retained, the client freezes the response at that
619
+ frontier through the request echo and controller decision. A rejected push or
620
+ decision removes the preview.
621
+ The reducer rewinds the indexed generation projection to that
733
622
  frontier and keeps the partial text the user actually saw. Tool parts whose
734
623
  arguments are still streaming are drafts: interruption removes them from the
735
624
  conversation and subsequent model prompts, even if their current JSON parses.
@@ -740,7 +629,25 @@ input, and compaction events remain in raw history but cannot reactivate it or
740
629
  alter the projection.
741
630
  Completed tool results at or before the visible frontier remain completed.
742
631
 
743
- For the lower-level `inputs.interrupt()` builder, between
632
+ Once Stop is persisted, A2's existing cancellation stream aborts running model
633
+ and tool work for that turn without waiting for the control handler. Startup
634
+ checks also skip matching jobs created while Stop is pending. Cancellation is
635
+ cooperative: tools must honor their abort signal. The control handler remains
636
+ responsible for recording the interruption and advancing the inbox; a Stop for
637
+ an older turn cannot cancel its successor.
638
+
639
+ `retry()` records `inputs.retry()` for the failed response, with a fresh
640
+ `retryId` for each click. The server reruns that failed model call.
641
+ Earlier completed model steps and their tool results remain, including steps
642
+ completed after the latest compaction. The committed summary also remains. For
643
+ example, if a tool succeeds after compaction and the next model call fails, retry
644
+ uses the summary and that tool's result.
645
+ Resending one retry is safe while a later retry remains distinct. The failed
646
+ model call's uncommitted step remains in
647
+ raw history but is excluded from the new prompt and response. Later user messages remain queued while
648
+ the failed response awaits an explicit retry or interruption.
649
+
650
+ The lower-level `inputs.interrupt()` builder addresses a specific owner. Between
744
651
  `ai.generation.requested` and `ai.generation.started`, copy
745
652
  `activeRequestId` as `requestId` and `activeResponseMessageId` as `messageId`.
746
653
  Once `activeGeneration` exists, send its `generationId` and
@@ -751,31 +658,20 @@ later work that reuses the response message. After a generation completes,
751
658
  copy the owner from a pending approval, pending input, or running tool while
752
659
  the response is still waiting.
753
660
 
754
- After a failed model call, `inputs.retry({ messageId, responseMessageId,
755
- retryId })` records a retry request. The server reruns that failed model call.
756
- Earlier completed model steps and their tool results remain, including steps
757
- completed after the latest compaction. The committed summary also remains. For
758
- example, if a tool succeeds after compaction and the next model call fails, retry
759
- uses the summary and that tool's result.
760
- `retryId` identifies the user's action, so resending one retry is safe while a
761
- later retry remains distinct. The failed model call's uncommitted step remains in
762
- raw history but is excluded from the new prompt and response. Later user messages remain queued while
763
- the failed response awaits an explicit retry or interruption.
764
-
765
661
  Applications own conversation forks and edit-and-resend flows for previously
766
662
  admitted messages. A2's retry operation continues the current response.
767
663
 
768
664
  ## Append from the server
769
665
 
770
- Browser interactions should use `push()` for instant state. Server-originated
666
+ Browser interactions go through the hook for instant state. Server-originated
771
667
  turns use the same inputs with `append()`:
772
668
 
773
669
  ```ts server/append-message.ts
774
670
  import { inputs } from 'experimental-a2/ai'
775
- import { assistantServer } from './assistant'
671
+ import { assistant } from './assistant'
776
672
 
777
673
  export async function appendServerMessage(sessionId: string, text: string) {
778
- await assistantServer.session(sessionId).append(
674
+ await assistant.session(sessionId).append(
779
675
  ...inputs.message({
780
676
  id: crypto.randomUUID(),
781
677
  role: 'user',
@@ -789,6 +685,51 @@ This path fits webhooks, jobs, and other backend sources. A connected browser
789
685
  still receives the events over SSE, but it has no optimistic overlay for an
790
686
  interaction that began elsewhere.
791
687
 
688
+ ## The built-in inputs
689
+
690
+ The hook's actions build these commands. `inputs` exposes the same pure,
691
+ isomorphic builders for raw `push()` and server `append()`:
692
+
693
+ | Input | Events |
694
+ | --- | --- |
695
+ | `inputs.message(message, { generate? })` | queues input; `generate: false` queues passive context |
696
+ | `inputs.seed(message)` | records `generate: false`; non-user roles require a trusted append |
697
+ | `inputs.approval(response)` | requests a decision on a pending approval |
698
+ | `inputs.input(response)` | records an application input response fact |
699
+ | `inputs.requestInput(request)` | records a trusted server request for application input |
700
+ | `inputs.retry(options)` | requests a fresh attempt for a failed response |
701
+ | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances |
702
+ | `inputs.steer({ turnId, message })` | prioritizes input after the observed turn's current step finishes |
703
+ | `inputs.queue.edit({ message })` | edits a pending input |
704
+ | `inputs.queue.move({ inputId, beforeId })` | reorders pending input; `null` means the end |
705
+ | `inputs.queue.remove({ inputId })` | removes pending input from the inbox |
706
+ | `inputs.queue.sendNow({ inputId, turnId })` | atomically prioritizes queued input and interrupts the observed turn |
707
+ | `inputs.pause({ when })` | pauses now or after the active turn |
708
+ | `inputs.resume()` | resumes retained work and inbox admission |
709
+ | `inputs.toolResult(result)` | records a trusted deferred provider-tool result |
710
+ | `inputs.interrupt(options)` | addresses a specific generation or request |
711
+
712
+ Builders return `ai.control.requested` commands. Message, approval, input, and
713
+ retry builders identify a stable interaction. Other controls use the event ID
714
+ assigned by `push()` or `append()`; transport retries preserve that ID.
715
+ Generation scheduling considers only user-role `ai.message.created` facts.
716
+ Assistant and system message facts remain context regardless of the `generate`
717
+ field, and model output is recorded as generation progress rather than a new
718
+ message fact, so an assistant reply cannot recursively schedule another turn.
719
+ Browser pushes accept user messages, approval and input responses,
720
+ interruptions, and explicit retries. `inputs.requestInput()` and non-user
721
+ messages built with `inputs.seed()` are for trusted server appends.
722
+ Browser inputs never append server scheduling, seeded non-user messages, or
723
+ lifecycle events.
724
+ Approval and input request/response payloads carry the active `generationId`;
725
+ clients copy it from the pending request so a stale interaction cannot satisfy
726
+ a later model step.
727
+
728
+ `inputs` deliberately has no session lifecycle methods. The A2 log begins with
729
+ the first append. Apps that use explicit `ai.session.created` or
730
+ `ai.session.closed` lifecycle events append those trusted server facts
731
+ directly.
732
+
792
733
  ## Compact long conversations
793
734
 
794
735
  Compaction is automatic for supported Gateway models. A2 uses the active model
@@ -797,11 +738,10 @@ an internal user message asking for a summary. This preserves the request prefix
797
738
  for prompt caching when the provider has a matching cache entry.
798
739
 
799
740
  ```ts server/compacted.ts
800
- import { createAgentServer } from 'experimental-a2/ai/server'
801
- import { assistant } from '../assistant'
741
+ import { agent } from 'experimental-a2/ai/server'
802
742
 
803
- export const compactedAssistantServer = createAgentServer({
804
- agent: assistant,
743
+ export const compactedAssistant = agent({
744
+ name: 'compacted-agent',
805
745
  model: 'openai/gpt-5.6-terra',
806
746
  })
807
747
  ```
@@ -812,7 +752,7 @@ handler fetches the public Gateway model catalog and records the selected limits
812
752
  in `ai.model.metadata.resolved`. It runs outside the AI turn lane. The first model
813
753
  request proceeds immediately; metadata that arrives afterward is available to
814
754
  later steps. Returning to a previously used model reuses its durable metadata.
815
- The catalog is also cached in memory for one hour across agent servers, with one
755
+ The catalog is also cached in memory for one hour across agents, with one
816
756
  shared in-flight request and a five-second timeout. A failed lookup follows A2's
817
757
  handler retry policy without failing the model generation. A model absent from
818
758
  the catalog is recorded as unavailable. A pending entry means no result has been
@@ -823,17 +763,18 @@ failure or use an explicit threshold in that case.
823
763
  The default threshold is 75% of the context window. A larger configured
824
764
  `generation.maxOutputTokens` lowers the threshold further to reserve that output
825
765
  allowance. This is an input-context policy, not a new output limit. Limits and
826
- metadata status are available through an explicit transcript reducer read.
766
+ metadata status are available in `state.modelMetadata`.
827
767
 
828
768
  `compaction: { thresholdTokens: 80_000 }` overrides discovery with an explicit
829
769
  positive safe integer. `compaction: { instructions: 'Preserve exact IDs.' }`
830
770
  adds guidance to the built-in summary prompt while keeping the derived threshold.
831
771
  `compaction: false` disables both compaction and metadata lookup. The `compaction`
832
772
  option can also be a resolver receiving the same
833
- `{ event, messages, session, signal }` context as `model` and `instructions`. It
834
- returns `false`, automatic options, or a custom policy once per generation and
835
- can await `session.state(settingsReducer)` to read application settings.
836
- Use this to select a threshold from application settings at the prompt boundary.
773
+ `{ event, messages, session, signal }` context as `model` and
774
+ `instructions`. It returns `false`, automatic options, or a custom policy once
775
+ per generation and can await `session.state(settingsReducer)` to read
776
+ application settings. Use this to select a threshold from application settings
777
+ at the prompt boundary.
837
778
  Changes after resolution apply to a later model step. Disabling compaction
838
779
  preserves summaries that were already recorded. Custom provider
839
780
  models and custom global SDK providers need an explicit threshold. A custom
@@ -870,36 +811,84 @@ the summary, its usage, and the exact event frontier it covers. Later model
870
811
  requests receive the summary plus everything produced after that frontier,
871
812
  including later tool steps in the same assistant message. Queued user messages
872
813
  remain queued. Compaction waits until earlier tool calls have terminal results.
873
- The raw log and transcript from `agent.reducer` remain available in full.
874
- Server execution uses a separate model-context reducer and checkpoint. Successful
875
- compaction permanently replaces covered context. Callbacks receive their prompt
876
- messages and request information, not reducer state or execution internals.
877
- Application reducers remain explicit reads through `session.state(reducer)`.
878
- To read the full transcript, load `session.state(agent.reducer)` and pass its
879
- `state` to `agent.reducer.view()`. Client hooks continue to expose `AIState`.
814
+ Server execution uses a separate model-context reducer and checkpoint.
815
+ Successful compaction permanently replaces covered context in model requests;
816
+ the raw log and the transcript in `AIState.messages` stay complete.
880
817
 
881
- Custom `shouldCompact(context)` and `compact(context)` callbacks remain
882
- available for deterministic replacement messages or application-specific
883
- retention. Both receive the typed UI messages and `modelMessages`, the model-ready
884
- context including any existing summary. Custom generation callbacks also receive
885
- `modelMessages`; use it when forwarding context to a provider. A2 stores built-in
886
- summaries separately from application-typed UI messages, so a summary does not
887
- need your message metadata or schema.
818
+ ### Show compaction in the conversation
888
819
 
889
- Render compaction as activity alongside the conversation:
820
+ The transcript marks where each compaction happened. When compaction is
821
+ requested, the reducer adds a `data-a2-compaction` part to the assistant
822
+ response, between the steps before it and the model step it compacted for. A
823
+ compaction before the first step opens the response with that part. Render it
824
+ in the same loop as the other parts:
890
825
 
891
- ```tsx app/agent/compaction-status.tsx
826
+ ```tsx app/agent/message-view.tsx
892
827
  'use client'
893
- import { useSession } from './session'
828
+ import type { UIMessage } from 'ai'
829
+ import { isCompactionPart, type TranscriptMessage } from 'experimental-a2/ai'
894
830
 
895
- export function CompactionStatus() {
896
- const { state } = useSession()
897
- return state.compaction?.status === 'running' ? (
898
- <p role="status">Compacting…</p>
899
- ) : null
831
+ const compactionLabel = {
832
+ running: 'Compacting…',
833
+ completed: 'Context compacted',
834
+ failed: 'Compaction did not complete',
835
+ interrupted: 'Compaction stopped',
836
+ }
837
+
838
+ export function MessageView({
839
+ message,
840
+ }: {
841
+ message: TranscriptMessage<UIMessage>
842
+ }) {
843
+ return (
844
+ <article>
845
+ {message.parts.map((part, index) => {
846
+ if (isCompactionPart(part))
847
+ return (
848
+ <details key={index}>
849
+ <summary>{compactionLabel[part.data.status]}</summary>
850
+ {part.data.summary ? <p>{part.data.summary}</p> : null}
851
+ </details>
852
+ )
853
+ return part.type === 'text' ? <p key={index}>{part.text}</p> : null
854
+ })}
855
+ </article>
856
+ )
900
857
  }
901
858
  ```
902
859
 
860
+ The part's `data` holds `status` plus, when known, `inputTokens`,
861
+ `thresholdTokens`, the `summary`, and its `usage`. It starts `running` and
862
+ settles in place: `completed` when the summary commits, `failed` when its
863
+ generation fails first, and `interrupted` when Stop, Pause now, or session
864
+ close ends it first. A pending Stop previews the part as `interrupted`
865
+ immediately. A worker attempt that recovery supersedes removes its part; the
866
+ replacement attempt compacts again if it still needs to. A completed part stays
867
+ through Stop cutoffs, later failures, and retries of the step that compacted,
868
+ because successful compaction is permanent. The part lives in the state, not the event
869
+ feed, so it survives server rendering, reloads, and other tabs. AI SDK
870
+ `convertToModelMessages()` skips data parts by default, and A2's model context
871
+ never includes it. `state.messages` is typed as `TranscriptMessage<M>[]`: your
872
+ message type with `CompactionPart` added to its parts, so `isCompactionPart()`
873
+ narrows even when your message type declares its own data parts.
874
+
875
+ `state.compaction` holds the current or latest compaction, and the hook's
876
+ `indicator` reports `compacting` while one runs.
877
+
878
+ Compaction runs only when retained tool exchanges have final results and all
879
+ A2-owned tool work has settled, including work from stopped or superseded turns.
880
+ A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
881
+ This adds one state read on that path. Unresolved dead-lettered tool work keeps
882
+ compaction deferred until repair and settlement or session closure.
883
+
884
+ Custom `shouldCompact(context)` and `compact(context)` callbacks remain
885
+ available for deterministic replacement messages or application-specific
886
+ retention. Both receive the typed UI messages and `modelMessages`, the model-ready
887
+ context including any existing summary. Custom generation callbacks also receive
888
+ `modelMessages`; use it when forwarding context to a provider. A2 stores built-in
889
+ summaries separately from application-typed messages, so a summary does not
890
+ need your message metadata or schema.
891
+
903
892
  Successful compaction is a permanent boundary. Failure, interruption, retry, and
904
893
  worker recovery never restore the messages it replaced. Retry reruns the failed
905
894
  model step from the latest committed context and preserves earlier completed
@@ -907,65 +896,190 @@ steps. A failed summary attempt leaves the previous committed context intact.
907
896
  The synthetic summary request is not a user turn or an assistant reply in
908
897
  `state.messages`.
909
898
 
910
- Compaction runs only when retained tool exchanges have final results and all
911
- A2-owned tool work has settled, including work from stopped or superseded turns.
912
- A2 checks a fresh coordinator checkpoint before starting a candidate compaction.
913
- This adds one state read on that path. Unresolved dead-lettered tool work keeps
914
- compaction deferred until repair and settlement or session closure.
899
+ ## Store assistant metadata
915
900
 
916
- ## Extend the assembly
901
+ Give the agent a narrower `UIMessage` type to keep generation status and usage
902
+ on the assistant message. `agentContract()` carries the type:
917
903
 
918
- The convenience API is ordinary A2 parts:
904
+ ```ts server/metadata-assistant.ts
905
+ import type { LanguageModelUsage, UIMessage } from 'ai'
906
+ import { agentContract } from 'experimental-a2/ai'
907
+ import { agent } from 'experimental-a2/ai/server'
908
+
909
+ export type MetadataAssistantMessage = UIMessage<{
910
+ status: 'writing' | 'complete'
911
+ usage?: LanguageModelUsage
912
+ }>
913
+
914
+ export const metadataAssistant = agent({
915
+ contract: agentContract<MetadataAssistantMessage>({
916
+ name: 'metadata-assistant',
917
+ }),
918
+ model: 'openai/gpt-5.6-terra',
919
+ messageMetadata: ({ part }) => {
920
+ if (part.type === 'start') return { status: 'writing' as const }
921
+ if (part.type === 'finish') {
922
+ return { status: 'complete' as const, usage: part.totalUsage }
923
+ }
924
+ return undefined
925
+ },
926
+ })
927
+ ```
928
+
929
+ The default generator passes `messageMetadata` to the AI SDK's
930
+ `toUIMessageStream()`. The callback receives the same context as `generate`,
931
+ plus its current `TextStreamPart` as `part`. Its return type follows the metadata type in the
932
+ agent's `UIMessage`. Metadata on standard `start` and `finish` chunks enters
933
+ the event log with the rest of the stream. A2 shallow-merges those values onto
934
+ the projected assistant message, so the example exposes `writing` immediately
935
+ and keeps final token usage after completion. The callback is synchronous.
936
+ `useAgent<typeof metadataAssistant>()` types `messages` with the same metadata.
919
937
 
920
- - `events` and `createEvents()` export the built-in schemas.
921
- - `createReducer({ contract })` builds the normalized state and `AIState` view for a
922
- compatible contract.
923
- - `agent()` combines the built-in protocol, application events, and reducer.
924
- - `tool()` binds an AI SDK function-tool definition to an agent's typed A2
925
- handler context.
926
- - `createHandlers({ agent, ... })` returns the AI handler table.
927
- - `createAgentServer()` combines those handlers with `createServer()`.
938
+ ## Add your own events and state
928
939
 
929
- Use the lower-level pieces when an application needs more handlers or a
930
- different server assembly:
940
+ Apps always carry more than the conversation: a plan, a selected ticket, a
941
+ chosen model. Put those events and the state they fold into in a shared
942
+ `agentContract()`. It takes a core `contract()`'s `events`, and its `reducer`
943
+ takes the same `name`, `initialState`, and `fold`:
931
944
 
932
- ```ts server/custom.ts
945
+ ```ts support-desk.ts
933
946
  import { z } from 'zod'
934
- import { agent } from 'experimental-a2/ai'
935
- import { createHandlers } from 'experimental-a2/ai/server'
936
- import { createServer } from 'experimental-a2/server'
947
+ import { agentContract } from 'experimental-a2/ai'
937
948
 
938
- const supportAgent = agent({
939
- name: 'extended-support-agent',
949
+ const noFlags: string[] = []
950
+
951
+ export const supportDesk = agentContract({
952
+ name: 'support-desk',
940
953
  events: {
941
- 'ticket.linked': z.object({ ticketId: z.string() }),
954
+ 'plan.changed': z.object({ plan: z.enum(['free', 'pro']) }),
955
+ 'ticket.flagged': z.object({ ticketId: z.string() }),
956
+ },
957
+ reducer: {
958
+ name: 'support-desk.v1',
959
+ initialState: { plan: 'free' as 'free' | 'pro', flagged: noFlags },
960
+ fold: (state, event) =>
961
+ event.type === 'plan.changed'
962
+ ? { ...state, plan: event.payload.plan }
963
+ : event.type === 'ticket.flagged'
964
+ ? { ...state, flagged: [...state.flagged, event.payload.ticketId] }
965
+ : state,
942
966
  },
943
967
  })
968
+ ```
944
969
 
945
- const aiHandlers = createHandlers({
946
- agent: supportAgent,
947
- model: 'openai/gpt-5.6-terra',
948
- })
970
+ Keep this module isomorphic. `fold` sees your state and every event, but not
971
+ the transcript. The session's state is `AIState` merged with yours, so a key
972
+ that shadows one of `AIState`'s is a type error. The reducer's `name` is its
973
+ snapshot identity: rename it when `fold` changes.
974
+
975
+ Serve it with `agent({ contract })`. Every resolver receives your state as
976
+ `state`, so model, reasoning level, and speed follow the app:
949
977
 
950
- export const customAssistantServer = createServer({
951
- contract: supportAgent.contract,
978
+ ```ts server/support-desk.ts
979
+ import { agent } from 'experimental-a2/ai/server'
980
+ import { supportDesk } from '../support-desk'
981
+
982
+ export const supportDeskAgent = agent({
983
+ contract: supportDesk,
984
+ model: ({ state }) =>
985
+ state.plan === 'pro'
986
+ ? { model: 'openai/gpt-5.6-terra', reasoning: 'high' }
987
+ : { model: 'openai/gpt-5.6-luna', speed: 'fast' },
952
988
  handlers: {
953
- ...aiHandlers,
954
- 'ticket.linked': async ({ event }) => {
989
+ 'ticket.flagged': async ({ event }) => {
955
990
  // your side effect; event.id makes a stable idempotency key:
956
- // await indexTicket(event.payload.ticketId, { idempotencyKey: event.id })
991
+ // await notifyOnCall(event.payload.ticketId, { idempotencyKey: event.id })
957
992
  },
958
993
  },
959
994
  })
960
995
  ```
961
996
 
962
- Application events stay fully typed. The standard AI reducer ignores unknown
963
- events, so another reducer can project application state without forking the AI
964
- protocol. A custom `createServer()` assembly owns its browser ingress policy.
965
- Use `server.fetch(request, { authorize })` to authorize operations, and reject
966
- server-only AI event names there before they reach the custom server.
967
- `createAgentServer()` applies its stricter built-in ingress allowlist
968
- automatically.
997
+ `state` is your state as of the snapshot the generation already captured. It
998
+ rides in the checkpoint every model step reads, so it costs no extra read. Each
999
+ model step resolves again, so a change during a turn applies from the next
1000
+ step. A resolver that throws fails the turn with its message, like a provider
1001
+ error; `retry()` runs it again.
1002
+
1003
+ In the browser, pass the contract to `useAgent()`. This import is real, not
1004
+ type-only, because the browser folds your state too:
1005
+
1006
+ ```tsx app/support/desk.tsx
1007
+ 'use client'
1008
+ import { useAgent } from 'experimental-a2/ai/react'
1009
+ import { supportDesk } from '@/support-desk'
1010
+
1011
+ export function Desk({ id }: { id: string }) {
1012
+ const { state, push, sendMessage } = useAgent({
1013
+ api: '/api/support',
1014
+ id,
1015
+ contract: supportDesk,
1016
+ })
1017
+
1018
+ return (
1019
+ <section>
1020
+ <p>
1021
+ {state.plan} plan, {state.flagged.length} flagged tickets
1022
+ </p>
1023
+ <button
1024
+ onClick={() =>
1025
+ void push({ type: 'ticket.flagged', payload: { ticketId: 'T-1' } })
1026
+ }
1027
+ >
1028
+ Flag T-1
1029
+ </button>
1030
+ <button onClick={() => void sendMessage('Summarize the flagged tickets.')}>
1031
+ Ask
1032
+ </button>
1033
+ </section>
1034
+ )
1035
+ }
1036
+ ```
1037
+
1038
+ Browsers push a shared contract's application events like any A2 client, and
1039
+ the route's `authorize` decides who may push what. The agent refuses only its
1040
+ own server-authored `ai.*` events. Here the plan comes from your billing code,
1041
+ not the browser:
1042
+
1043
+ ```ts app/api/support/route.ts
1044
+ import { supportDeskAgent } from '@/server/support-desk'
1045
+
1046
+ function handle(request: Request): Promise<Response> {
1047
+ // here's where you'd do auth, or any other checks:
1048
+ return supportDeskAgent.fetch(request, {
1049
+ authorize: (operation) =>
1050
+ operation.type !== 'push' ||
1051
+ operation.events.every((event) => event.type !== 'plan.changed'),
1052
+ })
1053
+ }
1054
+
1055
+ export const GET = handle
1056
+ export const POST = handle
1057
+ ```
1058
+
1059
+ Server appends, such as
1060
+ `supportDeskAgent.session(id).append({ type: 'plan.changed', payload })`, are
1061
+ trusted and skip `authorize`. `push()` also accepts any `inputs` builder, and
1062
+ `handlerContext(supportDesk)` types application events inside tools.
1063
+
1064
+ `agent({ contract })` takes only an `agentContract()`, so every resolver gets
1065
+ your `state`. When the agent belongs inside something larger, such as a chat
1066
+ room several people share, keep them as two sessions. The room is an ordinary
1067
+ core A2 session. Its handler appends each message the agent should see to the
1068
+ agent's session with `inputs.message()`. Event ids are unique across a store's
1069
+ sessions, so derive the message id from the room event's id with
1070
+ `idempotentId()`; a retried handler then converges on the same message. A tool
1071
+ the agent calls to speak appends to the room's session with an `idempotentId()`
1072
+ of its `toolCallId`, so the room's log orders the agent's messages among
1073
+ everyone else's. Neither fold ever reads the other's state.
1074
+
1075
+ The contract-free client, `createAgentClient()` and `useAgent({ api | client })`
1076
+ with a type-only import, folds the built-in reducer, so it accepts only agents
1077
+ whose reducer carries the built-in name: `agent({ name })`, or
1078
+ `agent({ contract })` with an `agentContract()` that has no `reducer`. The types
1079
+ enforce it through `AI_STATE_REDUCER`.
1080
+
1081
+ `reduceAIState()` remains a pure transition over confirmed `AIState` for
1082
+ applications that project raw event histories.
969
1083
 
970
1084
  ### Replace generation, not durability
971
1085
 
@@ -975,19 +1089,12 @@ SDK assembly:
975
1089
 
976
1090
  ```ts server/custom-generation.ts
977
1091
  import { convertToModelMessages, streamText } from 'ai'
978
- import { createAgentServer } from 'experimental-a2/ai/server'
979
- import { assistant } from '../assistant'
1092
+ import { agent } from 'experimental-a2/ai/server'
980
1093
 
981
- export const customGenerationServer = createAgentServer({
982
- agent: assistant,
1094
+ export const customGeneration = agent({
1095
+ name: 'custom-generation',
983
1096
  model: 'openai/gpt-5.6-terra',
984
- generate: async ({
985
- messages,
986
- model,
987
- instructions,
988
- responseMessageId,
989
- signal,
990
- }) => {
1097
+ generate: async ({ event, messages, model, instructions, signal }) => {
991
1098
  const result = streamText({
992
1099
  model,
993
1100
  messages: await convertToModelMessages(messages),
@@ -997,15 +1104,18 @@ export const customGenerationServer = createAgentServer({
997
1104
 
998
1105
  return result.toUIMessageStream({
999
1106
  originalMessages: messages,
1000
- generateMessageId: () => responseMessageId,
1107
+ generateMessageId: () => event.payload.responseMessageId,
1001
1108
  })
1002
1109
  },
1003
1110
  })
1004
1111
  ```
1005
1112
 
1006
- `generate` receives compacted messages, resolved model and instructions, tools,
1007
- generation settings, durable request identity, current state, and
1008
- the abort signal. It returns exactly one model step as a
1113
+ `generate` receives what every resolver does, `{ event, messages, state,
1114
+ session, signal }`, with `messages` already compacted. It also gets the
1115
+ resolved `model`, `instructions`, `tools`, and `generation` settings, the
1116
+ model-ready `modelMessages` with any compaction summary, and the step's
1117
+ `generationId`. Its stream writes the assistant message
1118
+ `event.payload.responseMessageId`. It returns exactly one model step as a
1009
1119
  `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
1010
1120
  the model without running local `execute` functions. A2 still owns durable
1011
1121
  progress, tool execution, approval, continuation, interruption, and failure.
@@ -1015,36 +1125,36 @@ emit typed metadata chunks itself.
1015
1125
 
1016
1126
  ## Checkpoints and recovery reads
1017
1127
 
1018
- The AI reducer stores normalized `AIStoredState`. `session.state(agent.reducer)`
1019
- returns that state and its log index, ready to pass to `SessionProvider` or client
1020
- hydration. Client and React snapshots derive the familiar `AIState` view
1021
- immediately, including streamed message parts and tool activity.
1022
-
1023
- For server-side inspection, use the reducer's view:
1128
+ `assistant.session(id).state()` returns `AIState` and its log index, ready for
1129
+ server code or client hydration. No conversion step is needed. The reducer's
1130
+ initial state and fold use that same type.
1024
1131
 
1025
1132
  ```ts server/read-agent.ts
1026
1133
  import type { UIMessage } from 'ai'
1027
- import { assistantServer } from './assistant'
1028
- import { assistant } from '../assistant'
1134
+ import type { TranscriptMessage } from 'experimental-a2/ai'
1135
+ import { assistant } from './assistant'
1029
1136
 
1030
- export async function readMessages(sessionId: string): Promise<UIMessage[]> {
1031
- const snapshot = await assistantServer.session(sessionId).state(assistant.reducer)
1032
- return assistant.reducer.view(snapshot.state).messages
1137
+ export async function readMessages(
1138
+ sessionId: string,
1139
+ ): Promise<TranscriptMessage<UIMessage>[]> {
1140
+ const snapshot = await assistant.session(sessionId).state()
1141
+ return snapshot.state.messages
1033
1142
  }
1034
1143
  ```
1035
1144
 
1036
- Each snapshot stores repeated payloads once and keeps only the changes needed
1037
- to restore the current generation's prompt and projection. The live view is cached while
1038
- events fold; normalization does not run for every token. Reading or serializing
1039
- the stored fields materializes the records. The log and displayed conversation
1040
- retain their full content.
1145
+ Tool payloads live in message parts. `state.tools` keeps only identities and
1146
+ execution status; it does not repeat inputs, outputs, or provider metadata.
1147
+ Checkpoint and subscription encoding remain internal. The active step retains
1148
+ its events and the information needed to restore overwritten message fields
1149
+ for interruption cutoffs and retry. It does not copy the whole prior message.
1150
+ Recovery is released when the response completes; completed model
1151
+ context is kept by the separate context reducer.
1041
1152
 
1042
1153
  Model steps read the model-context reducer and coordinator at a fixed log
1043
1154
  boundary. Model, instruction, compaction, and custom-generation callbacks
1044
- receive prompt messages and request information without reducer state. Custom transcript folds and
1045
- schemas apply only to the transcript; read application state explicitly with
1046
- `session.state(yourReducer)` when a callback needs it.
1047
- They do not receive a raw history array. Their `session.state(reducer)` reads
1155
+ receive prompt messages, request information, and the contract's application
1156
+ `state`, which rides in the model-context checkpoint. Read any other reducer
1157
+ with `session.state(yourReducer)`. They do not receive a raw history array. Their `session.state(reducer)` reads
1048
1158
  application reducers at the captured prompt boundary by default; explicit
1049
1159
  `through` options follow the ordinary state-read contract. Compaction selection
1050
1160
  can be asynchronous when it needs a settings-state read.
@@ -1108,10 +1218,10 @@ This input protocol requires matching client and server versions. Start a new
1108
1218
  agent session when adopting it. Existing event logs remain readable; finish
1109
1219
  pending work with the runtime that created it before upgrading.
1110
1220
 
1111
- `inputs.message(message)` places a message in the durable inbox. The controller
1112
- admits one input at a time, freezes its content, and adds it to `state.messages`.
1113
- Pending inputs appear in `state.inbox.items`; they do not enter the model prompt.
1114
- Each item has an `id`, `message`, and `generate` flag.
1221
+ `sendMessage()` places a message in the durable inbox. The controller
1222
+ admits one input at a time, freezes its content, and adds it to
1223
+ `state.messages`. Pending inputs appear in `state.inbox.items`; they do not enter
1224
+ the model prompt. Each item has an `id`, `message`, and `generate` flag.
1115
1225
 
1116
1226
  New-input commands from `inputs.message()`, `inputs.seed()`, and `inputs.steer()`
1117
1227
  use the message ID as their event ID. Those IDs must match on hand-written
@@ -1126,60 +1236,71 @@ different command action, or another session rejects at append with
1126
1236
  message ID to create another input. Edit, move, remove, and other commands have
1127
1237
  their own event IDs. The controller stores no historical input-ID registry.
1128
1238
 
1129
-
1130
1239
  `state.active` identifies the logical turn, its frozen `input`, and its `phase`.
1131
1240
  The turn ID stays the same across model steps, tools, and a pause. Stop ends that
1132
1241
  turn and admits the next queued input when admission is running.
1133
1242
 
1134
- ```ts client/turn-controls.ts
1135
- import { inputs, type AIState } from 'experimental-a2/ai'
1136
- import type { UIMessage } from 'ai'
1137
-
1138
- export function editPending(message: UIMessage) {
1139
- return inputs.queue.edit({ message })
1140
- }
1141
-
1142
- export function movePending(inputId: string, beforeId: string | null) {
1143
- return inputs.queue.move({ inputId, beforeId })
1144
- }
1145
-
1146
- export function removePending(inputId: string) {
1147
- return inputs.queue.remove({ inputId })
1148
- }
1149
-
1150
- export function sendPendingNow(state: AIState, inputId: string) {
1151
- return inputs.queue.sendNow({ inputId, turnId: state.active?.turnId ?? null })
1152
- }
1243
+ ```tsx app/agent/[sessionId]/queue.tsx
1244
+ 'use client'
1245
+ import { useAgent } from 'experimental-a2/ai/react'
1246
+ import type { assistant } from '@/server/assistant'
1153
1247
 
1154
- export function stopCurrent(state: AIState) {
1155
- return state.active ? inputs.stop({ turnId: state.active.turnId }) : []
1156
- }
1248
+ export function Queue({ sessionId }: { sessionId: string }) {
1249
+ const { state, queue, steer, pause, resume } = useAgent<typeof assistant>({
1250
+ api: '/api/agent',
1251
+ id: sessionId,
1252
+ })
1157
1253
 
1158
- export function steerCurrent(state: AIState, message: UIMessage) {
1159
- return state.active
1160
- ? inputs.steer({ turnId: state.active.turnId, message })
1161
- : inputs.message(message)
1254
+ return (
1255
+ <section>
1256
+ <ol aria-label="Queued messages">
1257
+ {state.inbox.items.map((item) => (
1258
+ <li key={item.id}>
1259
+ {item.message.parts.map((part, index) =>
1260
+ part.type === 'text' ? <span key={index}>{part.text}</span> : null,
1261
+ )}
1262
+ <button onClick={() => void queue.sendNow(item.id)}>Send now</button>
1263
+ <button
1264
+ onClick={() =>
1265
+ void queue.move({ inputId: item.id, beforeId: null })
1266
+ }
1267
+ >
1268
+ Move to end
1269
+ </button>
1270
+ <button onClick={() => void queue.remove(item.id)}>Remove</button>
1271
+ </li>
1272
+ ))}
1273
+ </ol>
1274
+ <button onClick={() => void steer('Check the billing service first.')}>
1275
+ Steer
1276
+ </button>
1277
+ {state.inbox.paused ? (
1278
+ <button onClick={() => void resume()}>Resume</button>
1279
+ ) : (
1280
+ <button onClick={() => void pause({ when: 'after-turn' })}>
1281
+ Pause after this turn
1282
+ </button>
1283
+ )}
1284
+ </section>
1285
+ )
1162
1286
  }
1163
-
1164
- export const pauseAfterTurn = () => inputs.pause({ when: 'after-turn' })
1165
- export const pauseNow = () => inputs.pause({ when: 'now' })
1166
- export const resume = () => inputs.resume()
1167
1287
  ```
1168
1288
 
1169
- Pass these arrays to `push(...events)` from `useSession()`, or to a server
1170
- session's `append(...events)`. `beforeId: null` moves an item to the end.
1171
- Removal only applies to pending input. It preserves the immutable log and keeps
1289
+ `queue.edit(message)` replaces a pending input's content. `queue.move()` takes
1290
+ `beforeId: null` to move an item to the end. Removal only applies to pending
1291
+ input. It preserves the immutable log and keeps
1172
1292
  its original submission event, so replay cannot recreate a removed prompt.
1173
1293
  Removing an input that is no longer pending returns `not-pending`, whether it was
1174
1294
  admitted, removed, or never queued.
1175
1295
 
1176
1296
  Edits to pending input use last-write-wins in session log order. Clients can send
1177
1297
  another edit before an earlier one is confirmed. Editing preserves the message's
1178
- identity and role; changing its role returns `role-mismatch`. An edit that loses
1179
- to admission returns `not-pending`. A Stop aimed at a completed turn returns
1180
- `stale-turn`.
1298
+ identity and role; changing its role returns `role-mismatch`. An edit
1299
+ that loses to admission returns `not-pending`. A Stop aimed at a completed turn
1300
+ returns `stale-turn`.
1181
1301
 
1182
- Steer validates the new input and places it ahead of the inbox in one decision.
1302
+ `steer()` validates the new input and places it ahead of the inbox in one
1303
+ decision. With no active turn, it sends like `sendMessage()`.
1183
1304
  If the observed turn is still active, it finishes its current model response and
1184
1305
  the tools produced by that response before handing off. Parallel tools all
1185
1306
  settle, and steering runs before the old turn starts another model step.
@@ -1196,11 +1317,10 @@ first behind a different active turn. Stop remains an immediate interruption.
1196
1317
  Steering preserves the admission gate: steering while paused does not resume
1197
1318
  execution. Duplicate input identities and closed sessions still reject the command.
1198
1319
 
1199
- Send now uses the selected queued message's existing identity and latest
1200
- accepted content. It validates the selected input and the observed `turnId`,
1201
- then moves the input first for the same handoff after the current step. Use
1202
- `turnId: null` when no turn is active. If that expectation is stale, the command
1203
- returns `stale-turn` without changing the queue.
1320
+ `queue.sendNow()` uses the selected queued message's existing identity and
1321
+ latest accepted content. It validates the selected input and the observed turn,
1322
+ then moves the input first for the same handoff after the current step. If that
1323
+ expectation is stale, the command returns `stale-turn` without changing the queue.
1204
1324
 
1205
1325
  Send now preserves pause just like steering. A paused queue keeps the selected
1206
1326
  input first until Resume. It accepts user-role inputs, including passive user
@@ -1215,12 +1335,14 @@ queue controls which input is admitted at that boundary.
1215
1335
 
1216
1336
  ### Optimistic conversation display
1217
1337
 
1218
- With `agent().reducer` or `createReducer()`, the client state is already optimistic.
1219
- An idle, unpaused send appears in `state.messages` immediately, with
1220
- `state.starting: true`. Another send waits in `state.inbox.items` while that
1221
- optimistic turn or a confirmed turn is active. Paused sessions keep new sends in
1222
- the inbox. Render the conversation and queue from `state`; `state.active` and
1223
- execution controls remain server-confirmed.
1338
+ The AI reducer is optimistic in every client, including one built from an
1339
+ extended `agentContract()`. An idle, unpaused send appears in `state.messages`
1340
+ immediately, with `state.starting: true`. Another send waits in
1341
+ `state.inbox.items` while that optimistic turn or a confirmed turn is active.
1342
+ Paused sessions keep new sends in the inbox. Render the conversation and queue
1343
+ from `state`. A pending Stop clears the displayed `state.active` turn
1344
+ immediately; rejection restores it. New active turn identities still come from
1345
+ accepted control decisions.
1224
1346
  Steering appears immediately in `state.messages`, after the current response,
1225
1347
  and stays there while that step finishes. Send now has the same display when
1226
1348
  an active turn is targeted. These inputs are omitted from the client inbox,
@@ -1229,45 +1351,19 @@ Accepted steering survives receipt and hydration in this display. Multiple
1229
1351
  steering inputs appear in admission order, with the most recent first.
1230
1352
  No additional request, subscription, or component-owned queue is needed.
1231
1353
 
1232
- ```tsx app/agent/[sessionId]/conversation.tsx
1233
- 'use client'
1234
- import { useSession } from '../session'
1235
-
1236
- export function Conversation() {
1237
- const { state } = useSession()
1238
- const { messages, inbox, starting } = state
1239
- const text = (message: (typeof messages)[number]) =>
1240
- message.parts.filter((part) => part.type === 'text').map((part) => part.text).join(' ')
1241
- return (
1242
- <section>
1243
- <div aria-label="Conversation">
1244
- {messages.map((message) => <p key={message.id}>{text(message)}</p>)}
1245
- {starting ? <p role="status">Starting…</p> : null}
1246
- </div>
1247
- <ol aria-label="Queued messages">
1248
- {inbox.items.map((item) => <li key={item.id}>{text(item.message)}</li>)}
1249
- </ol>
1250
- </section>
1251
- )
1252
- }
1253
- ```
1254
-
1255
1354
  The request echo keeps the optimistic display until `ai.control.decided` arrives. Accepted
1256
1355
  commits replace the confirmed base; rejected receipts remove the pending intent.
1257
1356
  A failed POST rolls back through the ordinary client optimistic overlay.
1258
1357
  Another client's accepted turn can move a speculative ordinary send back into
1259
- the queue. Steering stays visible as a message while waiting behind that turn. `state.starting` describes an optimistic turn awaiting acceptance; it does
1358
+ the queue. Steering stays visible as a message while waiting behind that turn.
1359
+ `state.starting` describes an optimistic turn awaiting acceptance; it does
1260
1360
  not indicate a running model.
1261
1361
 
1262
- Server `session.state(reducer)`, reducer folds, checkpoints, and model prompts
1362
+ Server `session.state()`, reducer folds, checkpoints, and model prompts
1263
1363
  contain confirmed messages and inbox entries, with `starting: false`. The client
1264
1364
  applies pending intent and displays accepted steering only when returning its
1265
1365
  state, after folding events.
1266
- A custom reducer can keep normalized AI state by nesting `agent.reducer.fold()`
1267
- and derive its view with `agent.reducer.view()`. It does not inherit the built-in
1268
- optimistic client display. `reduceAIState()` remains a pure transition over the
1269
- confirmed `AIState` view for applications that project raw event histories.
1270
- `state.pendingQueueCommands` tracks unresolved queue commands incrementally, so
1366
+ `state.pendingQueueCommands` tracks unresolved queue commands and Stops incrementally, so
1271
1367
  the client does not scan raw history. Pending edits apply in order, so the latest
1272
1368
  local edit stays visible while earlier commands are confirmed. Editing and Send
1273
1369
  now can remain available while an edit awaits its receipt. Close an editor as
@@ -1275,12 +1371,16 @@ soon as its save is pushed; retain the draft if the push fails.
1275
1371
 
1276
1372
  ### Persistence and application are separate
1277
1373
 
1278
- `push()` confirms that the command is persisted. The `ai.control.decided` event
1374
+ A push confirms that the command is persisted. The `ai.control.decided` event
1279
1375
  confirms its application, using the submitted event's ID as `commandId`. Its
1280
- `outcome` is `applied` or `rejected`; a rejection includes a `reason`.
1376
+ `outcome` is `applied` or `rejected`; a rejection includes a `reason`, such as
1377
+ `not-pending` or `stale-turn`.
1281
1378
  `state.receipt` exposes the most recent user command receipt. Applications that
1282
1379
  have several commands in flight correlate receipts through the existing event
1283
- stream. A rejection is a normal result and does not spend a handler retry.
1380
+ stream: a hook action resolves to its persisted command events, and each
1381
+ event's `id` is the `commandId` of its receipt. An action with nothing to
1382
+ address, such as Stop with no active turn, resolves to an empty array. A
1383
+ rejection is a normal result and does not spend a handler retry.
1284
1384
 
1285
1385
  ### Pause and resume
1286
1386
 
@@ -1305,4 +1405,4 @@ can replay older events when a snapshot is missing.
1305
1405
 
1306
1406
  For a deferred provider-executed tool, trusted server code submits
1307
1407
  `inputs.toolResult(result)`. The controller checks its admitted call and records
1308
- the result before continuing. Browser ingress rejects this server-only input.
1408
+ the result before continuing. Browser pushes reject this server-only input.