experimental-a2 0.17.0 → 0.18.1

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 (221) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
  4. package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-react.d.ts +3 -3
  7. package/dist/actor-react.js +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +2 -2
  10. package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
  11. package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
  12. package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
  13. package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
  14. package/dist/ai-client.d.ts +2 -2
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.browser.d.ts +1 -0
  17. package/dist/ai-fx.browser.js +6 -0
  18. package/dist/ai-fx.browser.js.map +1 -0
  19. package/dist/ai-fx.d.ts +10 -0
  20. package/dist/ai-fx.d.ts.map +1 -0
  21. package/dist/ai-fx.js +692 -0
  22. package/dist/ai-fx.js.map +1 -0
  23. package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
  24. package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
  25. package/dist/ai-harness-BuLh_EYi.js +273 -0
  26. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  27. package/dist/ai-pi.browser.d.ts +1 -0
  28. package/dist/ai-pi.browser.js +6 -0
  29. package/dist/ai-pi.browser.js.map +1 -0
  30. package/dist/ai-pi.d.ts +17 -0
  31. package/dist/ai-pi.d.ts.map +1 -0
  32. package/dist/ai-pi.js +950 -0
  33. package/dist/ai-pi.js.map +1 -0
  34. package/dist/ai-react.d.ts +5 -5
  35. package/dist/ai-react.js +2 -2
  36. package/dist/ai-server.d.ts +47 -25
  37. package/dist/ai-server.d.ts.map +1 -1
  38. package/dist/ai-server.js +113 -40
  39. package/dist/ai-server.js.map +1 -1
  40. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  41. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  42. package/dist/ai.d.ts +1 -1
  43. package/dist/ai.js +1 -1
  44. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  45. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  46. package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
  47. package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
  48. package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
  49. package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
  50. package/dist/client.d.ts +3 -3
  51. package/dist/client.js +1 -1
  52. package/dist/index.d.ts +5 -5
  53. package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
  54. package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
  55. package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
  56. package/dist/internal-CscSY7K_.js.map +1 -0
  57. package/dist/otel.d.ts +1 -1
  58. package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
  59. package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
  60. package/dist/react.d.ts +1 -1
  61. package/dist/react.js +1 -1
  62. package/dist/reducer-BAXc8JyF.js.map +1 -1
  63. package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
  64. package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
  65. package/dist/scheduler-qstash.d.ts +2 -2
  66. package/dist/scheduler-qstash.js +3 -3
  67. package/dist/scheduler-qstash.js.map +1 -1
  68. package/dist/scheduler-vercel.d.ts +7 -7
  69. package/dist/scheduler-vercel.d.ts.map +1 -1
  70. package/dist/scheduler-vercel.js +9 -10
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
  73. package/dist/server-D-Y4pqwu.js.map +1 -0
  74. package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
  75. package/dist/server-DYAATFuT.d.ts.map +1 -0
  76. package/dist/server.d.ts +4 -4
  77. package/dist/server.js +1 -1
  78. package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  79. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  80. package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
  81. package/dist/store-CyLUy86v.d.ts.map +1 -0
  82. package/dist/store-N8PXxDAS.js.map +1 -1
  83. package/dist/store-memory.d.ts +1 -1
  84. package/dist/store-memory.js +56 -11
  85. package/dist/store-memory.js.map +1 -1
  86. package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
  87. package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  88. package/dist/store-postgres.d.ts +1 -1
  89. package/dist/store-postgres.d.ts.map +1 -1
  90. package/dist/store-postgres.js +106 -25
  91. package/dist/store-postgres.js.map +1 -1
  92. package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
  93. package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  94. package/dist/store-redis-http.d.ts +1 -1
  95. package/dist/store-redis-http.js +3 -3
  96. package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
  97. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  98. package/dist/store-redis.d.ts +1 -1
  99. package/dist/store-redis.js +1 -1
  100. package/dist/store-sqlite.d.ts +1 -1
  101. package/dist/store-sqlite.js +66 -21
  102. package/dist/store-sqlite.js.map +1 -1
  103. package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  104. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  105. package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
  106. package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
  107. package/docs/01-quickstart.mdx +49 -83
  108. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  109. package/docs/actors/01-introduction.mdx +48 -86
  110. package/docs/actors/02-concurrency.mdx +43 -70
  111. package/docs/actors/03-timers.mdx +28 -42
  112. package/docs/actors/04-routes.mdx +110 -150
  113. package/docs/actors/meta.ts +1 -1
  114. package/docs/agents/01-introduction.mdx +257 -0
  115. package/docs/agents/02-tools.mdx +247 -0
  116. package/docs/agents/03-conversations.mdx +538 -0
  117. package/docs/agents/04-harnesses.mdx +136 -0
  118. package/docs/agents/meta.ts +1 -0
  119. package/docs/client/01-react.mdx +294 -0
  120. package/docs/client/02-transports.mdx +86 -0
  121. package/docs/client/03-presence.mdx +224 -0
  122. package/docs/client/meta.ts +1 -0
  123. package/docs/contract/01-events.mdx +57 -0
  124. package/docs/contract/02-state.mdx +142 -0
  125. package/docs/contract/03-sessions.mdx +175 -0
  126. package/docs/index.mdx +109 -145
  127. package/docs/reference/01-core.mdx +139 -0
  128. package/docs/reference/02-server.mdx +860 -0
  129. package/docs/reference/03-client.mdx +301 -0
  130. package/docs/reference/04-ai.mdx +731 -0
  131. package/docs/reference/05-adapters.mdx +351 -0
  132. package/docs/reference/06-errors.mdx +138 -0
  133. package/docs/reference/meta.ts +1 -1
  134. package/docs/server/01-handlers.mdx +200 -0
  135. package/docs/server/02-timers.mdx +130 -0
  136. package/docs/server/03-cancellation.mdx +142 -0
  137. package/docs/server/04-routes.mdx +243 -0
  138. package/docs/server/05-production.mdx +323 -0
  139. package/docs/server/06-guarantees.mdx +132 -0
  140. package/examples/playground/DEPLOY.md +3 -5
  141. package/examples/playground/app/a0/[appId]/page.tsx +12 -13
  142. package/examples/playground/app/a0/app-list.tsx +9 -2
  143. package/examples/playground/app/a0/app-view.tsx +9 -1
  144. package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
  145. package/examples/playground/app/a0/apps.test.ts +12 -0
  146. package/examples/playground/app/a0/apps.ts +22 -7
  147. package/examples/playground/app/a0/chat.tsx +47 -1
  148. package/examples/playground/app/a0/devtools.tsx +2 -8
  149. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  150. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  151. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  152. package/examples/playground/app/a0/model.test.ts +18 -1
  153. package/examples/playground/app/a0/model.ts +47 -10
  154. package/examples/playground/app/a0/no-photon.cjs +4 -0
  155. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  156. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  157. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  158. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  159. package/examples/playground/app/a0/sandbox-log.ts +10 -4
  160. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  161. package/examples/playground/app/a0/scheduler/route.ts +2 -2
  162. package/examples/playground/app/a0/server.ts +98 -54
  163. package/examples/playground/app/a0/session.ts +17 -5
  164. package/examples/playground/app/a0/shell.tsx +11 -3
  165. package/examples/playground/app/agent/ingress.test.ts +6 -3
  166. package/examples/playground/app/agent/server.ts +9 -6
  167. package/examples/playground/app/agent/telemetry.test.ts +71 -50
  168. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
  169. package/examples/playground/app/chat/server.test.ts +30 -26
  170. package/examples/playground/app/chat/server.ts +46 -46
  171. package/examples/playground/next.config.ts +17 -5
  172. package/examples/playground/package.json +4 -1
  173. package/examples/playground/vercel.json +18 -0
  174. package/package.json +27 -3
  175. package/src/ai-agent.ts +271 -90
  176. package/src/ai-fx.browser.ts +5 -0
  177. package/src/ai-fx.ts +912 -0
  178. package/src/ai-harness.ts +518 -0
  179. package/src/ai-model-metadata.ts +11 -6
  180. package/src/ai-pi.browser.ts +5 -0
  181. package/src/ai-pi.ts +1322 -0
  182. package/src/ai-sdk-step.ts +2 -2
  183. package/src/ai-server.ts +3 -1
  184. package/src/ai-tool-input.ts +45 -0
  185. package/src/internal.ts +13 -0
  186. package/src/libfx.d.ts +34 -0
  187. package/src/reducer.ts +1 -1
  188. package/src/scheduler-qstash.ts +5 -5
  189. package/src/scheduler-vercel.ts +14 -15
  190. package/src/server.ts +233 -29
  191. package/src/store-memory.ts +92 -16
  192. package/src/store-postgres.ts +156 -25
  193. package/src/store-redis-core.ts +322 -110
  194. package/src/store-redis-notify.ts +2 -0
  195. package/src/store-sqlite.ts +121 -28
  196. package/src/store.ts +55 -3
  197. package/src/telemetry.ts +1 -0
  198. package/dist/internal-CbVvEwad.js.map +0 -1
  199. package/dist/server-CGM8WzRG.d.ts.map +0 -1
  200. package/dist/server-Rj64fNvX.js.map +0 -1
  201. package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
  202. package/dist/store-Gpkq7WBo.d.ts.map +0 -1
  203. package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
  204. package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
  205. package/docs/concepts/01-contracts.mdx +0 -237
  206. package/docs/concepts/02-handlers.mdx +0 -284
  207. package/docs/concepts/03-durability.mdx +0 -314
  208. package/docs/concepts/04-state.mdx +0 -209
  209. package/docs/guides/01-timers.mdx +0 -193
  210. package/docs/guides/02-cancellation.mdx +0 -161
  211. package/docs/guides/03-react.mdx +0 -324
  212. package/docs/guides/04-local-first.mdx +0 -47
  213. package/docs/guides/05-production.mdx +0 -534
  214. package/docs/guides/06-ai-agents.mdx +0 -1408
  215. package/docs/guides/08-application-data.mdx +0 -113
  216. package/docs/guides/09-presence.mdx +0 -262
  217. package/docs/guides/10-transports.mdx +0 -221
  218. package/docs/reference/01-api.mdx +0 -2358
  219. package/docs/reference/02-errors.mdx +0 -150
  220. /package/docs/{concepts → contract}/meta.ts +0 -0
  221. /package/docs/{guides → server}/meta.ts +0 -0
@@ -1,2358 +0,0 @@
1
- ---
2
- title: API
3
- description: "The whole surface, small enough to read in one sitting: contract, server, session, reducer, and the React and HTTP helpers."
4
- ---
5
-
6
- ## `experimental-a2`
7
-
8
- ### `a2.contract(options)`
9
-
10
- ```ts
11
- a2.contract(options: {
12
- name: string
13
- events: Record<string, StandardSchemaV1>
14
- presence?: Record<string, StandardSchemaV1>
15
- reducer?: { name: string; initialState: S; stateSchema?: StandardSchemaV1; fold: (state: S, event: Event) => S }
16
- }): Contract
17
- ```
18
-
19
- Defines one kind of lifecycle: a name plus the events it understands. The name
20
- must be unique per app; it identifies the contract in storage and queue
21
- messages. The name may not contain the control character U+001F, and event
22
- types may not start with `a2.`, which A2 reserves for its own events, such as
23
- `a2.cleared`. The result is a plain,
24
- importable, **isomorphic** value: the server
25
- implements it, reducers derive from it, the browser types its pushes off it.
26
- See [Contracts and sessions](/concepts/contracts).
27
-
28
- `presence` is the optional second vocabulary: each key a field of one
29
- participant's ephemeral state, each value its schema. Presence fields are
30
- replicated last-writer-wins and never stored in the log; reducers cannot
31
- see them. See [Presence](/guides/presence).
32
-
33
- Schemas are [Standard Schema](https://standardschema.dev): Zod, Valibot,
34
- ArkType, anything that implements it. Validators must be synchronous
35
- (async ones are rejected here, at definition time), and the validated
36
- output is what's stored.
37
-
38
- ### `contract.batch(...events)`
39
-
40
- Preserves the literal types in a heterogeneous handler-returned event array.
41
- It returns the same events as a readonly tuple; A2 validates them when the
42
- handler completes.
43
-
44
- ### `reducer(options)`
45
-
46
- ```ts
47
- reducer(options: {
48
- contract: Contract
49
- name: string // identity; keys cached snapshots
50
- initialState: S // the seed (schema input, when stateSchema is set)
51
- stateSchema?: StandardSchemaV1 // declares S, checks initialState, guards cached folds
52
- }): { fold(fn: (state: S, event: Event) => S): Reducer<S> }
53
- ```
54
-
55
- Builds a separate reducer for server reads or for the contract's optional
56
- `reducer` field. The contract publishes only that selected reducer to clients.
57
- Inline definitions include `fold` directly. The standalone builder uses two steps: the first call
58
- fixes the state and event types, so `.fold()` receives fully concrete
59
- ones. Literal unions in your state survive, with zero annotations.
60
-
61
- The name is the reducer's identity; it keys cached snapshots, so rename it
62
- when the fold's logic changes. Two reducers never fight over a cache
63
- entry, and a rename (or a `stateSchema` mismatch, when present)
64
- invalidates, and the next read refolds from raw events. See
65
- [Reading state](/concepts/state).
66
-
67
- ### `A2Error`
68
-
69
- The shared base for A2 failures, discriminated by `code`. The session
70
- client exposes its `A2ClientError` subclass. See
71
- [Errors](/reference/errors).
72
-
73
- ### `idempotentId(...parts)`
74
-
75
- `idempotentId(...parts: string[]): Promise<string>` derives a stable UUIDv8
76
- from an ordered tuple of strings. It runs on the server and in the browser.
77
-
78
- ```ts shared/request-id.ts
79
- import { idempotentId } from 'experimental-a2'
80
-
81
- export const id = await idempotentId('orders', 'shop-7', 'request-42')
82
- ```
83
-
84
- Pass the result as an event's `id` when retries should converge on the same
85
- event. The same tuple produces the same ID across runtimes and A2 versions.
86
- String boundaries, order, and case matter; strings are not normalized.
87
- Empty strings and an empty tuple are valid inputs.
88
-
89
- Every argument has the same role. A prefix such as `'orders'` is a namespace
90
- by convention. Include enough context to distinguish operations across
91
- contracts and sessions, since event IDs are globally unique within a store.
92
-
93
- The helper performs no I/O. A2 preserves explicit IDs and keeps its automatic
94
- ID generation unchanged. Changing an existing application's ID recipe changes
95
- retry identity, so retries of previously committed requests need to retain
96
- their original IDs.
97
-
98
- ### `SessionCleared`
99
-
100
- The marker a [clear](#sessionclearoptions) leaves in `session.stream()`: an
101
- `Event` whose `type` is `'a2.cleared'` and whose `payload` is `null`. It
102
- never reaches handlers or `history()`. Comparing `event.type` with that string
103
- narrows a stream item to `SessionCleared`. The session client handles the
104
- marker on its own, so this matters only when you read the server stream
105
- yourself.
106
-
107
- ## `experimental-a2/server`
108
-
109
- ### `handler(entry)`
110
-
111
- Define handlers inline in `createServer({ handlers })` for ordinary use.
112
- Use this helper when composing an existing handler.
113
-
114
- Accepts a handler function or an object with `handler` and optional `lane` and
115
- `abortOn`. Returns the object shape: a function becomes `{ handler: entry }`,
116
- and an object is returned unchanged. The original function and object types
117
- are preserved. Use it to compose an existing handler without branching on its
118
- shape.
119
-
120
- ```ts server/composed-orders.ts
121
- import * as a2 from 'experimental-a2'
122
- import { createServer, handler, type HandlerContext } from 'experimental-a2/server'
123
- import { z } from 'zod'
124
-
125
- const orders = a2.contract({
126
- name: 'composed-orders',
127
- events: {
128
- created: z.object({ shopId: z.string() }),
129
- notified: z.object({ shopId: z.string() }),
130
- },
131
- })
132
-
133
- const created = handler({
134
- lane: 'orders',
135
- handler: async (ctx: HandlerContext<typeof orders.events, 'created'>) => {
136
- return orders.batch({ type: 'notified', payload: ctx.event.payload })
137
- },
138
- })
139
-
140
- export const ordersServer = createServer({
141
- contract: orders,
142
- handlers: {
143
- created: {
144
- ...created,
145
- handler: async (ctx) => {
146
- const result = await created.handler(ctx)
147
- // Your additional work goes here:
148
- // await notifyShop(ctx.event.payload, { idempotencyKey: ctx.event.id })
149
- return result
150
- },
151
- },
152
- },
153
- })
154
- ```
155
-
156
- Spreading the object preserves its `lane` and `abortOn`. Set either property
157
- after the spread to override it. Changing a built-in AI handler's lane can
158
- separate it from the handlers it coordinates with.
159
-
160
- Composition runs in one handler attempt. Work after the original function
161
- resolves still runs before its returned events commit. A thrown error fails
162
- the composed attempt; on retry, the wrapper and original handler can run again.
163
- For a new handler, annotate its context as above or pass an already typed
164
- function. `handler` does not bind a handler to a contract;
165
- `createServer({ handlers })` checks that it matches the contract.
166
-
167
- ### `createServer(options)`
168
-
169
- ```ts
170
- createServer(options: {
171
- contract: Contract // the vocabulary this server implements
172
- store?: A2Store // default: sqlite in dev, memory in tests, required in prod
173
- scheduler?: A2Scheduler
174
- telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
175
- presence?: { ttlMs?: number } // presence expiry policy; default 60s
176
- handlers?: {
177
- [type]:
178
- | Handler
179
- | {
180
- abortOn?: AbortSpec
181
- lane?: string | ((ctx: LaneContext) => string)
182
- handler: Handler
183
- }
184
- }
185
- }): A2Server
186
-
187
- type Handler = (
188
- ctx: Context,
189
- ) => Promise<void | AppendInput | readonly AppendInput[]>
190
- ```
191
-
192
- Implements a contract: binds the vocabulary to storage and reactions.
193
- Handlers are one table, complete at construction, so a handler can never
194
- be silently missing because the module that registered it wasn't
195
- imported. Compose across files by spreading objects into `handlers`
196
- (note: a duplicate key under spread silently last-wins).
197
-
198
- Handlers are concurrent by default. A2 starts every eligible event after its
199
- append commits. Add `lane` when events share a resource and must not overlap.
200
- Within one session, events with the same lane value run one at a time in log
201
- order. Different lanes and events without a lane run concurrently. A lane
202
- resolver receives `{ sessionId, event }`; A2 resolves and stores the value when
203
- the event is appended, so a later deployment cannot reinterpret pending work.
204
- For example, `lane: ({ event }) => event.payload.warehouseId` serializes work
205
- per warehouse while different warehouses continue concurrently.
206
-
207
- Handlers can return one event or an array. A2 marks the triggering event
208
- processed and appends the returned batch in one atomic store operation. Returned
209
- events do not exist when the handler throws. `ctx.session.append(name, ...events)`
210
- is different: it commits immediately, so its events may run while the current
211
- handler is still active unless a lane orders them.
212
- `ctx.session.schedule(name, timing, ...events)` waits for provider acceptance;
213
- its events append later and are not part of the parent completion transaction.
214
-
215
- Handlers are optional per event type. An event type without one settles in
216
- the append transaction with no dispatch attempt. If the session has no older
217
- pending handler work, A2 starts no drain or scheduler arm. A server with no
218
- handlers is a durable event log with no reaction infrastructure. See
219
- [Events without handlers](/concepts/handlers#events-without-handlers).
220
-
221
- Server-only by construction: `experimental-a2/server` is the only entry point that
222
- can reach a store backend, and its exports map resolves to a loud error
223
- under the browser condition.
224
-
225
- `presence.ttlMs` sets how long a presence value survives without a
226
- refreshing set (default 60 seconds, as a positive integer of
227
- milliseconds). Expiry counts on the storage's own clock, never on the
228
- sender's stamp. The option is valid only on contracts that declare
229
- `presence`; anywhere else construction throws a `TypeError`.
230
-
231
- `abortOn` names the events that fire `ctx.signal` while a handler runs.
232
- an array matches by type; an object takes per-type predicates for
233
- targeted cancellation:
234
-
235
- ```ts
236
- generate: {
237
- abortOn: {
238
- cancelled: (event, trigger, { attempt }) =>
239
- event.payload.of === `${trigger.id}:${attempt}`,
240
- },
241
- handler: async (ctx) => { /* ... */ },
242
- }
243
- ```
244
-
245
- The predicate's third argument contains the triggering event's durable
246
- `attempt`, so a cancellation can fence a specific recovered run.
247
-
248
- The context every handler receives:
249
-
250
- | Property | Type |
251
- | ------------- | --------------------------------------------------------- |
252
- | `ctx.event` | `Event`: the triggering event |
253
- | `ctx.attempt` | durable 1-based dispatch claim ordinal |
254
- | `ctx.session` | this session's `id`, `append`, `schedule`, `history`, `state`, `stream`, `setPresence` |
255
- | `ctx.signal` | `AbortSignal`: fires on lease lapse (`CLAIM_EXPIRED`), proven supersession (`SUPERSEDED_ATTEMPT`), and matching `abortOn` events |
256
-
257
- `ctx.attempt` starts at `1` and increments on every durable claim. It may skip
258
- when a process dies before handler entry.
259
-
260
- `ctx.session.id` equals `ctx.event.sessionId`. Its `history`, `state`, and
261
- `stream` methods share the session surface returned by `server.session(id)`,
262
- with one causal default: `ctx.session.state(reducer)` folds through the
263
- triggering event's index. The same event sees the same state across concurrent
264
- appends and retries. Ask for `{ through: 'latest' }` when a handler intentionally
265
- needs the committed frontier captured when that read runs.
266
-
267
- The handler-local append is specialized:
268
-
269
- ```ts
270
- ctx.session.append(name: string, ...events: AppendInput[]): Promise<Event[]>
271
- ```
272
-
273
- The name is an idempotency key scoped to the triggering event for events whose
274
- `id` is omitted. A2 derives each omitted id from the trigger id, name, and event
275
- position. The same name and generated-id batch returns the existing rows
276
- across retries. Changing that batch conflicts with those rows. An explicit
277
- event `id` wins over the generated id, which lets different triggering events
278
- converge on one fact. A root `server.session(id).append(...events)` takes no
279
- name.
280
-
281
- `ctx.session.schedule(name, timing, ...events)` includes the triggering event
282
- in its stable task identity. A relative delay is anchored to that event's
283
- durable `createdAt`, so a handler retry keeps the same delivery time. The call
284
- returns `Promise<void>` after the configured scheduler accepts the task.
285
-
286
- A latest state read and following append are not atomic. Concurrent appends and
287
- retries may move the frontier between them. A generic join should read with
288
- `{ through: 'latest' }`, use a monotone readiness predicate, and give its output
289
- a stable explicit event `id`, so every eligible attempt converges on the same
290
- append.
291
-
292
- ### `server.fetch(request, options?)`
293
-
294
- ```ts
295
- server.fetch(
296
- request: Request,
297
- options?: {
298
- authorize?: (operation: A2Operation) => boolean | Promise<boolean>
299
- upgradeWebSocket?: (
300
- attach: (socket: A2Socket) => void,
301
- ) => Response | Promise<Response>
302
- multiplexWebSocket?: boolean
303
- },
304
- ): Promise<Response>
305
-
306
- type A2Operation =
307
- | {
308
- type: 'stream'
309
- sessionId: string
310
- startAfter: number
311
- transport: 'http' | 'websocket'
312
- }
313
- | {
314
- type: 'history'
315
- sessionId: string
316
- gte: number
317
- lte: number
318
- transport: 'http'
319
- }
320
- | {
321
- type: 'push'
322
- sessionId: string
323
- events: readonly {
324
- type: ContractEventType | string
325
- payload: unknown
326
- id?: string
327
- }[]
328
- presence?: {
329
- participant: string
330
- values: Readonly<Record<string, unknown>>
331
- seen?: number
332
- at?: number
333
- }
334
- transport: 'http' | 'websocket'
335
- }
336
- ```
337
-
338
- The bound Fetch API route for this server. An unprotected route can export it
339
- directly:
340
-
341
- ```ts app/api/public-order-events/route.ts
342
- import { ordersServer } from '@/server/orders'
343
-
344
- export const GET = ordersServer.fetch
345
- export const POST = ordersServer.fetch
346
- ```
347
-
348
- A plain GET streams one session over SSE, resumed after the `index` query
349
- parameter. A GET with `gte` and `lte` returns the closed history range as JSON
350
- and sets `a2-history-covered` to `true` when every requested index was returned.
351
- When the range includes the latest clear marker, it also sets `a2-clear-index`,
352
- and coverage counts only the requested indexes after that marker. A range
353
- entirely below the marker returns `[]`.
354
- POST accepts `{ sessionId, events, presence? }` and returns the appended events.
355
- Presence is inferred from the contract. A contract that declares presence
356
- streams it and accepts presence pushes automatically.
357
-
358
- `authorize` runs after the request or socket frame has been parsed, but before
359
- contract schema validation, presence validation, or I/O. Event names and
360
- presence field names are typed from the contract. Their unvalidated values stay
361
- `unknown`. Return `false` to deny the operation with `FORBIDDEN`. A thrown error
362
- becomes `STORE_UNAVAILABLE`, because authorization infrastructure failed rather
363
- than denied access. Operation objects, push events, event arrays, and presence
364
- patches are frozen. Authorize them, do not rewrite them.
365
-
366
- Authentication stays outside A2, where the route can read headers, cookies, and
367
- framework context. Capture the authenticated principal in `authorize`:
368
-
369
- ```ts app/api/protected-order-events/route.ts
370
- import { ordersServer } from '@/server/orders'
371
-
372
- async function fetchOrders(request: Request): Promise<Response> {
373
- // your authentication:
374
- const user = { id: 'user-1' }
375
- if (!user) return new Response(null, { status: 401 })
376
-
377
- return ordersServer.fetch(request, {
378
- authorize(operation) {
379
- // your per-session and per-operation authorization:
380
- // return canAccess(user, operation)
381
- return true
382
- },
383
- })
384
- }
385
-
386
- export const GET = fetchOrders
387
- export const POST = fetchOrders
388
- ```
389
-
390
- Direct `session.append`, handler appends, scheduled appends, and server-side
391
- `setPresence` bypass `authorize`. It is an ingress policy for this fetch call,
392
- not a global server policy.
393
-
394
- A GET with `Upgrade: websocket` uses `upgradeWebSocket`. Without that option it
395
- returns 426. The upgrade function receives an `attach` callback for the
396
- platform socket:
397
-
398
- ```ts app/api/socket-order-events/route.ts
399
- import { experimental_upgradeWebSocket } from '@vercel/functions'
400
- import { ordersServer } from '@/server/orders'
401
-
402
- export const POST = ordersServer.fetch
403
-
404
- export function GET(request: Request): Promise<Response> {
405
- return ordersServer.fetch(request, {
406
- upgradeWebSocket: (attach) =>
407
- experimental_upgradeWebSocket(attach, {
408
- maxPayload: 4 * 1024 * 1024,
409
- }),
410
- })
411
- }
412
- ```
413
-
414
- By default, the `sessionId` and `index` query parameters bind one session to the
415
- socket. A2 authorizes the stream before upgrading. Frames on that socket cannot
416
- select another session.
417
-
418
- Set `multiplexWebSocket: true` here and `multiplex: true` in the client's `ws`
419
- API to share one socket.
420
- Authenticate the physical WebSocket GET before calling `server.fetch`. Validate
421
- `Origin` too when browser cookies authenticate it; WebSocket handshakes do not
422
- use a CORS preflight. Use `authorize` for each session subscribe and each event
423
- or presence push. Only enable multiplexing when that request context may access
424
- every session accepted by `authorize`. A denied subscribe receives
425
- `unsubscribed` and does not affect other sessions. A denied event push receives
426
- a `FORBIDDEN` ack. Denied presence is silently dropped because it is
427
- fire-and-forget.
428
-
429
- SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
430
- A2 also uses the platform's ambient `waitUntil` capability for background work.
431
- Neither capability is reordered or passed through `server.fetch` options.
432
-
433
- ### `server.session(id)`
434
-
435
- ```ts
436
- server.session(id: string): Session
437
- ```
438
-
439
- A handle on one instance of the contract. The session is the unit of log
440
- ordering, lane keys, recovery, state, and live sync. Handler execution is
441
- concurrent unless events share a lane. Creating the handle does no I/O;
442
- nothing loads until you append, read, or stream.
443
-
444
- ```ts
445
- const session = server.session('order-42')
446
-
447
- session.id
448
- session.append(...events)
449
- session.schedule('expire-order', { delay: '5d' }, {
450
- type: 'expired',
451
- payload: {},
452
- })
453
- session.history({ gte, lte })
454
- session.state(reducer)
455
- session.stream({ startAfter })
456
- ```
457
-
458
- `session.id` is the id passed to `server.session(id)`. Root `append` takes only
459
- events. The handler-local form at `ctx.session.append` adds its required name
460
- before the events. Both root and handler sessions expose `schedule`.
461
-
462
- ### `server.drain(sessionId)`
463
-
464
- ```ts
465
- server.drain(sessionId: string): Promise<{ settled: boolean }>
466
- ```
467
-
468
- Claims every currently eligible event and runs their handlers concurrently.
469
- `settled` means no actionable or live-claimed work remains. A dead-lettered
470
- event can leave later work in its lane blocked while other lanes continue.
471
- You'll rarely call this yourself; it is the primitive scheduler callbacks use.
472
- The public result stays this simple boolean.
473
-
474
- ### `A2Store`
475
-
476
- Custom adapters implement these atomic drain methods:
477
-
478
- ```ts
479
- // Custom server store adapter:
480
- type EventCause = {
481
- index: number
482
- attempt: number
483
- batchSize?: number // present on named handler appends
484
- }
485
-
486
- type AppendEvent = {
487
- type: string
488
- payload: unknown
489
- id?: string
490
- cause?: EventCause
491
- lane?: string
492
- settled?: true // internal: no handler is registered for this event type
493
- }
494
-
495
- type ReturnedEvent = AppendEvent & { id: string }
496
-
497
- type StoredEvent = Event & {
498
- cause: EventCause | null
499
- lane: string | null
500
- processedAt: Date | null
501
- processedByAttempt: number | null
502
- returnedEventIds: string[] | null
503
- firstClaimedAt: Date | null
504
- lastClaimedAt: Date | null
505
- attemptCount: number
506
- claimHolder: string | null
507
- claimExpiresAt: Date | null
508
- failureCount: number
509
- lastFailedAt: Date | null
510
- lastFailedAttempt: number | null
511
- lastError: string | null
512
- failedAt: Date | null
513
- clearIndex?: number // on claimed events, once the session has been cleared
514
- }
515
-
516
- type StoreAppendResult = {
517
- events: StoredEvent[]
518
- hasPending: boolean
519
- }
520
-
521
- type StoreClaimAvailableResult =
522
- | { outcome: 'claimed'; events: StoredEvent[] }
523
- | { outcome: 'busy'; retryAt: Date }
524
- | { outcome: 'settled' }
525
-
526
- type CompleteAttemptResult =
527
- | { outcome: 'completed'; events: StoredEvent[] }
528
- | { outcome: 'superseded' }
529
-
530
- interface A2Store {
531
- append(
532
- sessionId: string,
533
- events: AppendEvent[],
534
- options?: { originIndex?: number },
535
- ): Promise<StoreAppendResult>
536
-
537
- // optional together; required for session.clear()
538
- clear?(options: {
539
- sessionId: string
540
- ifLastIndex?: number
541
- cause?: EventCause
542
- }): Promise<{ cleared: boolean; index: number; clearIndex: number }>
543
- reclaim?(sessionId: string): Promise<{ deleted: number; done: boolean }>
544
-
545
- claimAvailable(options: {
546
- sessionId: string
547
- holder: string
548
- ttlMs: number
549
- expiresAtMs?: number
550
- excludeIndexes?: readonly number[]
551
- }): Promise<StoreClaimAvailableResult>
552
-
553
- renewClaims(options: {
554
- sessionId: string
555
- holder: string
556
- claims: readonly { index: number; attempt: number }[]
557
- ttlMs: number
558
- expiresAtMs?: number
559
- }): Promise<{ renewed: number[]; superseded: number[] }>
560
-
561
- completeAttempt(options: {
562
- sessionId: string
563
- index: number
564
- attempt: number
565
- events: ReturnedEvent[]
566
- }): Promise<CompleteAttemptResult>
567
-
568
- failAttempt(options: {
569
- sessionId: string
570
- index: number
571
- attempt: number
572
- error: string
573
- maxFailures: number
574
- }): Promise<{
575
- outcome: 'failed' | 'dead_lettered' | 'superseded'
576
- failureCount: number
577
- }>
578
- }
579
- ```
580
-
581
- | Method | Atomic effect |
582
- | --- | --- |
583
- | `append` | Write a consecutive batch. Events carrying core's internal `settled` flag receive `processedAt` in the same transaction, with no dispatch attempt. An event carrying `cause` is fenced by attempt currency: it commits only while `cause.attempt` is still the parent's latest attempt and the parent is not dead-lettered, throwing `SUPERSEDED_ATTEMPT` otherwise (the check serializes against a concurrent claim; the idempotent-replay path runs first). |
584
- | `claimAvailable` | Claim every eligible event. All unlaned events are independent; only the lowest-index unfinished event in each lane is eligible. A claim records its holder, expiry, timestamps, and next `attemptCount`. |
585
- | `renewClaims` | Extend the listed live claims still owned by the holder. `renewed` lists the claims that remain owned; `superseded` lists the claims whose event's `attemptCount` passed the listed attempt. An expired claim no successor took appears in neither: it may still complete. Renewal never revives an expired claim. |
586
- | `completeAttempt` | Fence on the current attempt, mark the parent processed, store its exact ordered `returnedEventIds`, and append the returned batch in the same transaction. A same-attempt retry returns the committed children; a stale attempt returns `superseded`. |
587
- | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. A repeated failure acknowledgment is idempotent; stale attempts return `superseded`. |
588
- | `clear` | Serialized with `append`. Check `ifLastIndex` against the head and the latest marker, and append a settled clear marker at the next index, touching no existing rows. A `cause` is fenced like a handler append. |
589
- | `reclaim` | Delete one bounded batch of rows below the latest clear marker, with their bookkeeping, event-id records, lane pointers, and cached state. Report how many rows went and `done` when nothing is left. Never wait on a lock that other methods hold. |
590
-
591
- A store that supports clearing treats every event at or below the latest clear
592
- marker as superseded, whether or not its row still exists. `claimAvailable`
593
- never claims it, lane readiness at append and at completion ignores it (a
594
- dead-lettered lane head stops blocking), `renewClaims` reports it in
595
- `superseded`, `completeAttempt` and `failAttempt` return `superseded`, and an
596
- `append` whose `cause` names it throws `SUPERSEDED_ATTEMPT`. An `append` whose
597
- `originIndex` is at or below the marker throws `SESSION_CLEARED`. Reads start
598
- at the marker, `readState` returns its index as `clearIndex`, and claimed
599
- events carry it as `clearIndex`. In `append` and `completeAttempt`, an event id
600
- whose row is below this session's marker is free to reuse. Each check belongs
601
- inside the operation's existing atomic step. Without `clear` and `reclaim`,
602
- `session.clear()` and `session.schedule.clear()` throw a `TypeError` before
603
- writing anything.
604
-
605
- `attemptCount` counts claims, including abandoned ones. `failureCount` counts
606
- caught failures and dead-letters at ten. See
607
- [Durability](/concepts/durability#one-event-many-attempts) for the recovery
608
- model. `cause` identifies the event and handler attempt whose
609
- `ctx.session.append` or return value first persisted the child. A null cause
610
- means a top-level event.
611
- `lane` is the session-scoped serialized group resolved before append.
612
- `returnedEventIds` makes a lost completion
613
- acknowledgment recoverable without accepting a partial child batch. Lifecycle
614
- timestamps are adapter clock values for their atomic store operations, not exact
615
- database commit times.
616
- Built-in adapters persist these fields inside their existing atomic operations,
617
- with no extra backend round trip.
618
-
619
- ### `A2Scheduler`
620
-
621
- Custom scheduler adapters implement two methods:
622
-
623
- | Method | Effect |
624
- | --- | --- |
625
- | `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
626
- | `handler(...servers)` | return the route that receives drain, append, and clear tasks |
627
-
628
- Application routes call `scheduler.handler(...servers)` on the configured
629
- adapter. The handler requires at least one drainable server and rejects
630
- duplicate contract names synchronously.
631
-
632
- `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask |
633
- SchedulerClearTask`. These task types, plus `ScheduledEvent`, are exported
634
- from `experimental-a2/server`.
635
-
636
- | Task | Fields |
637
- | --- | --- |
638
- | `SchedulerDrainTask` | `version: 1`, `kind: 'drain'`, `contract`, `sessionId`, required epoch-ms `dueAt` |
639
- | `SchedulerAppendTask` | `version: 1`, `kind: 'append'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, readonly `events`, optional `originIndex` |
640
- | `SchedulerClearTask` | `version: 1`, `kind: 'clear'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, `ifLastIndex` |
641
- | `ScheduledEvent` | fixed `id`, `type`, and JSON `payload` |
642
-
643
- Drain tasks wake pending work and carry no application payload. Append tasks
644
- carry the events for `session.schedule`; `originIndex` is the scheduling
645
- handler's event, absent for root schedules. Clear tasks carry the condition
646
- for `session.schedule.clear`.
647
-
648
- Custom adapters deliver an authenticated, normalized append or clear task
649
- through the server helpers:
650
-
651
- ```ts custom-scheduler.ts
652
- import {
653
- deliverSchedulerAppend,
654
- deliverSchedulerClear,
655
- type DrainableServer,
656
- type SchedulerAppendTask,
657
- type SchedulerClearTask,
658
- } from 'experimental-a2/server'
659
-
660
- export async function deliver(
661
- server: DrainableServer,
662
- task: SchedulerAppendTask | SchedulerClearTask,
663
- ) {
664
- if (task.kind === 'clear') await deliverSchedulerClear(server, task)
665
- else await deliverSchedulerAppend(server, task)
666
- }
667
- ```
668
-
669
- Import both from `experimental-a2/server`. Each verifies the task and server
670
- contract match. `deliverSchedulerAppend` revalidates the carried event inputs
671
- and uses the ordinary top-level append path. The target server must have a
672
- scheduler. When the append leaves pending handler work, the helper resolves
673
- only after a recovery watchdog is durable; a send failure keeps the provider
674
- delivery retryable. A task whose `originIndex` was cleared resolves without
675
- appending.
676
-
677
- `deliverSchedulerClear` runs a conditional `clear({ ifLastIndex })` and
678
- resolves after its deletion finishes. A store failure rejects, so the provider
679
- redelivers and the retry finishes deleting. Authenticate the transport and
680
- validate the task envelope before calling either helper.
681
-
682
- ## Session
683
-
684
- ### `session.append(...events)`
685
-
686
- ```ts
687
- session.append(
688
- ...events: Array<{ type: string; payload: unknown; id?: string }>
689
- ): Promise<Event[]>
690
- ```
691
-
692
- The only way to move a session forward. Payloads are validated against
693
- the contract's schemas before anything is written. A multi-event append
694
- is atomic: all-or-nothing, consecutive positions, one transaction. Pass
695
- `id` to make an append idempotent across retries; re-sending an
696
- identical batch returns the original rows. (Events that arrived through
697
- `server.fetch`'s push lane are accepted directly.) See
698
- [Durability](/concepts/durability).
699
-
700
- After the write, A2 dispatches only event types with registered handlers.
701
- Other event types are already settled by the append itself. An unhandled
702
- append still starts session healing when older handled work is pending. The
703
- store reports that session-wide pending state as part of the atomic append, so
704
- this decision needs no follow-up read.
705
-
706
- ### `session.append.dispatch(...events)`
707
-
708
- ```ts
709
- session.append.dispatch(
710
- ...events: Array<{ type: string; payload: unknown; id?: string }>
711
- ): Promise<Event[]>
712
- ```
713
-
714
- Commits the same atomic batch, but sends pending work directly to configured
715
- scheduler instead of starting an inline drain. The call awaits acceptance of an
716
- immediate scheduler message. It throws before writing if the server has no
717
- scheduler adapter.
718
-
719
- If the queue send fails, the events are already durable. Give them explicit
720
- IDs and retry the same dispatch safely. A batch that leaves no pending handler
721
- work does not send a scheduler message. Dispatch chooses how this append wakes
722
- the session; it does not reserve events for one worker. A drain that is already
723
- active may still claim newly eligible work first.
724
-
725
- `dispatch` exists only on a server session's top-level append. Handler-scoped
726
- `ctx.session.append(name, ...events)` already runs inside an active drain and
727
- does not expose `dispatch`. Its immediate children and atomically returned
728
- children become eligible in that drain.
729
-
730
- ### `session.schedule(name, timing, ...events)`
731
-
732
- `schedule` returns `Promise<void>` and is available on both root and handler
733
- sessions. `name` must be non-empty. At least one event is required.
734
- `ScheduleDelay`, `ScheduleTiming`, and `SessionSchedule` are exported from
735
- `experimental-a2/server`.
736
-
737
- | Timing | Meaning |
738
- | --- | --- |
739
- | `{ delay: '30s' }` | relative delay with unit `ms`, `s`, `m`, `h`, or `d` |
740
- | `{ at: Date }` | absolute delivery time |
741
-
742
- The template type checks the unit suffix. At runtime, the delay number must be
743
- an unsigned base-10 decimal without leading zeros, finite, and greater than
744
- zero. `0s`, signs, exponent notation, malformed durations, invalid dates, and
745
- objects containing both timing fields are rejected. A root relative delay
746
- starts from that call's wall clock. A handler relative delay starts from the
747
- triggering event's durable `createdAt`, so retries preserve its delivery time.
748
- Use `{ at }` when a retried root call needs one target time. The requested
749
- target is the earliest useful delivery time. Adapters encode provider timing
750
- in whole-second slots. An already-due target is sent immediately.
751
-
752
- The stable task id derives from the contract, session, and name. Handler tasks
753
- also include the triggering event id. Omitted event ids derive from that task
754
- id and event position; explicit ids win. Reuse a name only for an identical
755
- timing and event batch. Provider deduplication is an optimization. Stable event
756
- ids make later duplicate deliveries converge in the store.
757
-
758
- A2 snapshots payloads into plain JSON trees, then validates the event schemas
759
- before sending. Encode dates as ISO strings and custom classes as plain data;
760
- values such as `undefined`, bigint, functions, symbols, cycles, accessors, and
761
- non-plain prototypes are rejected. The promise resolves after provider
762
- acceptance, not after append. Shared object references become independent JSON
763
- values. The task carries the snapshotted JSON schema input. At delivery, the
764
- ordinary top-level append validates that input, so a schema
765
- transform produces the stored payload from the original input. The events have
766
- `cause: null` and enter normal handlers, lanes, state, and streams.
767
-
768
- A task scheduled from a handler belongs to its triggering event's lifetime. If
769
- the session is cleared before delivery, the task is acknowledged without
770
- appending. Once a session has been cleared, handler task ids also include the
771
- clear, so a trigger id reused in the new lifetime schedules a new task. A root
772
- task has no triggering event, so its events join whichever lifetime exists when
773
- it is delivered, and its name identifies one task across lifetimes. Put
774
- something from the new lifetime in the name when you schedule again after a
775
- clear.
776
-
777
- There is no cancellation, reschedule, cron, or timer-listing API. See
778
- [Timers and delays](/guides/timers) for stale-timer guards, provider limits,
779
- and deployment compatibility.
780
-
781
- ### `session.clear(options?)`
782
-
783
- ```ts
784
- session.clear(options?: { ifLastIndex?: number }): Promise<{
785
- cleared: boolean
786
- index: number
787
- }>
788
- ```
789
-
790
- End the session's current lifetime. One atomic step checks the condition and
791
- appends a reserved clear marker at the next index. The session id stays usable
792
- and indexes never restart. `ifLastIndex` is a non-negative safe integer; the
793
- first matching rule decides:
794
-
795
- | When | Result |
796
- | --- | --- |
797
- | the latest marker is at `ifLastIndex + 1` | `{ cleared: true, index }`, a retry of that clear |
798
- | `ifLastIndex` is given and is not the head | `{ cleared: false, index }`, nothing changes |
799
- | the head is `0` or already a clear marker | `{ cleared: true, index }`, nothing changes |
800
- | otherwise | `{ cleared: true, index }`, the new marker |
801
-
802
- `index` is always the head after the call. Every event at or below the marker
803
- is superseded: it is never dispatched again and never blocks its lane, its
804
- running handler's appends throw `SUPERSEDED_ATTEMPT`, and that handler's state
805
- reads never fold the new lifetime: they throw `SESSION_CLEARED`, except that
806
- after a clear from another instance, a read bounded at its own event may return
807
- that event's state from memory until the handler's `ctx.signal` aborts. Pushes and appends after the marker start the
808
- new lifetime.
809
-
810
- `clear()` then deletes the old events, their bookkeeping and event-id records,
811
- and cached state below the marker in short batches, and resolves when they are
812
- gone. Other work on the session keeps running between batches. If deletion
813
- fails, the call rejects with the session already cleared; any later clear of
814
- the session, including a retry, finishes the deletion. There is no background
815
- sweep. Event ids from the old lifetime can be used again. Presence is not
816
- cleared.
817
-
818
- On `ctx.session`, the clear is fenced like the handler's appends and ends the
819
- handler's own attempt: `ctx.signal` aborts with `SUPERSEDED_ATTEMPT` and
820
- returned events are discarded. Its deletion keeps running under the platform's
821
- `waitUntil`. All built-in stores support clearing. See
822
- [Clearing a session](/concepts/contracts#clearing-a-session).
823
-
824
- ### `session.schedule.clear(name, options)`
825
-
826
- ```ts
827
- session.schedule.clear(
828
- name: string,
829
- options: ({ delay: ScheduleDelay } | { at: Date }) & {
830
- ifLastIndex: number
831
- },
832
- ): Promise<void>
833
- ```
834
-
835
- Publish a conditional clear to the configured scheduler. `name` is non-empty
836
- and `ifLastIndex` is required. Timing has the same meaning and provider limits
837
- as `session.schedule`. The task id derives from the contract, session, name,
838
- and condition, plus the triggering event id and any earlier clear inside a
839
- handler; reuse a name only for the same timing. The promise resolves after
840
- provider acceptance.
841
-
842
- Delivery runs `clear({ ifLastIndex })` and acknowledges after the deletion
843
- finishes. A stale or duplicate delivery changes nothing. No event is appended.
844
- Indexes never repeat, so a condition from an older lifetime never matches a
845
- newer one. Clear tasks, appends, and recovery drains all use the Vercel
846
- scheduler's `deploymentId` setting.
847
-
848
- `ifLastIndex: ctx.event.index` matches only while the handler's event is still
849
- the head. The handler's own appends and returned events move the head, so a
850
- handler that writes should use the index of its last `ctx.session.append`
851
- instead. Actor handlers always commit state, so they can't use the trigger's
852
- index. A delay short enough to deliver while the handler is still running can
853
- match before those writes land; the clear then ends the handler's attempt and
854
- discards its returned events.
855
-
856
- Each delivery attempt resumes the deletion where the last one stopped. Vercel
857
- Queues redelivers until retention ends. QStash retries one message up to its
858
- `retries` budget; a deletion that outlasts every retry leaves the message in
859
- QStash's dead-letter queue with the rest of the old history still invisible,
860
- and any later clear of the session finishes it.
861
-
862
- ### `session.history()`
863
-
864
- ```ts
865
- session.history(options?: { gte?: number; lte?: number }): Promise<Event[]>
866
- ```
867
-
868
- Raw events from the session, oldest first. `gte` and `lte` are inclusive event
869
- indexes; omit a bound to leave that end of the log open.
870
- Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
871
- After a clear, history starts after the marker. The marker itself is not
872
- returned.
873
-
874
- Redis reads a fixed committed range in pages of at most 256 events, with up to
875
- sixteen page reads in flight. Public history omits dispatch metadata. PostgreSQL
876
- uses one indexed range query selecting only public event columns. Custom store
877
- adapters can implement `readHistory(sessionId, options)` with the same exclusive
878
- `afterIndex` and inclusive `throughIndex` bounds as `read()`. A2 falls back to
879
- `read()` when that capability is absent.
880
-
881
- ### `session.state(reducer, options?)`
882
-
883
- ```ts
884
- session.state(
885
- reducer: Reducer<S>,
886
- options?: { through?: number | 'latest' },
887
- ): Promise<{ state: S; index: number; clearIndex?: number }>
888
- ```
889
-
890
- The log folded through one committed prefix. `index` is its exact boundary,
891
- an append-order cursor rather than a handler-settlement marker. Hand it to the
892
- client to resume there. Snapshot write-back uses platform `waitUntil`; this
893
- read never dispatches handlers or waits for pending work.
894
-
895
- On a root session from `server.session(id)`, omitting `through` reads the latest
896
- committed frontier. On the handler-scoped `ctx.session`, omitting it reads
897
- through `ctx.event.index`. That boundary is inclusive and stable across retries.
898
- Use `{ through: 'latest' }` inside a handler only when later committed events
899
- are intentionally part of the decision. A numeric `through` is an inclusive,
900
- non-negative safe integer; `through: 0` returns the reducer's initial state.
901
-
902
- Once a session has been cleared, the result includes `clearIndex`, the latest
903
- marker, and state folds from there. Forward the whole result when hydrating a
904
- client. A positive `through` below `clearIndex` throws `SESSION_CLEARED`, and
905
- `through: 0` returns the seed without `clearIndex`. On
906
- `ctx.session`, every state read throws it once the triggering event has been
907
- cleared, including `{ through: 'latest' }`. Handler `history()` and `stream()`
908
- are not fenced.
909
-
910
- The default handler read and numeric handler reads automatically retain their
911
- exact folded checkpoint while the triggering event is unfinished. Completion
912
- or dead-lettering releases the reference and collects the checkpoint when no
913
- other unfinished handler uses it. There are no retention options. Handler
914
- `{ through: 'latest' }` reads and all root reads do not retain history.
915
-
916
- If the latest snapshot is ahead of a numeric boundary, A2 can use the greatest
917
- eligible retained checkpoint. A root historical read never creates retention
918
- or writes a stale checkpoint behind the latest snapshot; it folds from the log
919
- when no eligible checkpoint exists.
920
-
921
- Operational snapshot-read failures preserve existing A2 errors; other errors
922
- become `STORE_UNAVAILABLE` with the original error as the cause. They do not retry through individual
923
- reads or raw history. Cache misses and `stateSchema` rejection still rebuild
924
- from the log. Snapshot write-back remains background work; failures are reported
925
- through `a2.snapshot` telemetry or the default console sink, without failing the
926
- completed read or attaching snapshot contents.
927
-
928
- ### `session.stream(options?)`
929
-
930
- ```ts
931
- session.stream(options?: { startAfter?: number }): AsyncIterable<Event | SessionCleared>
932
- session.stream(options: {
933
- startAfter?: number
934
- presence: true
935
- }): AsyncIterable<Event | SessionCleared | PresencePatch | PresenceSnapshot>
936
- ```
937
-
938
- A live feed of the session's events. `startAfter` is a non-negative safe integer;
939
- `startAfter: 20` begins with event 21.
940
- Server-side only; `server.fetch` exposes it over SSE and WebSocket. Subscribing
941
- never dispatches handlers.
942
-
943
- A clear arrives in order as a `SessionCleared` marker, whose `type` is
944
- `'a2.cleared'`. A stream that opens below the
945
- latest marker begins with it. A stream that is already running delivers the
946
- log in order, so events from before the clear can arrive ahead of the marker.
947
- Reset when the marker arrives. Clearing never closes a stream.
948
-
949
- With `presence: true`, the feed yields one `PresenceSnapshot` first:
950
- `{ snapshot }`, the current pruned map with each field's own `value`,
951
- `seen`, and `at` stamp. Live presence patches then interleave with
952
- events. The stream response sends both as named SSE frames, so clients
953
- that don't know them skip them. The return type widens only under the
954
- literal `presence: true`; without it, existing consumers keep
955
- `AsyncIterable<Event>`. The option itself exists only on sessions of
956
- contracts that declare `presence`; elsewhere it is a type error, not a
957
- runtime throw. A `PresencePatch` is `{ participant, values, seen, at }`
958
- with `values` typed by the contract's presence schemas (`null` marks a
959
- cleared field).
960
-
961
- ### `session.setPresence(patch)`
962
-
963
- ```ts
964
- session.setPresence(patch: {
965
- participant: string
966
- values: { [field]: payload | null } // partial; null clears a field
967
- seen?: number
968
- at?: number // the sender's LWW stamp, epoch ms; receipt time when absent
969
- }): Promise<void>
970
- ```
971
-
972
- Validates each field against the contract's `presence` schemas, then
973
- broadcasts. Last writer wins per participant per field, ordered by
974
- `at`, the sender's stamp in epoch milliseconds (the browser client
975
- stamps its sends automatically). When `at` is absent, receipt time
976
- stands in: a stampless caller is a single writer, so receipt order is
977
- its send order. No log row, no
978
- dispatch, no scheduler arm; `history()` and `state()` never see it. A
979
- bad payload throws `INVALID_PAYLOAD`; an unknown field throws
980
- `UNKNOWN_PRESENCE_FIELD`; a non-finite or negative `at` throws a
981
- `TypeError`. `seen` is the sender's stream frontier, the
982
- stamp view code uses to reconcile presence against the fold (see
983
- [Presence](/guides/presence#reconciling-the-planes-seen)). Values expire
984
- by backend TTL when a participant goes silent (default 60 seconds; set
985
- `createServer({ presence: { ttlMs } })` to change it). Expiry counts on
986
- the storage clock, not the sender stamp, so a hostile stamp can only
987
- vandalize its own field and still expires on schedule.
988
-
989
- The patch is structurally the `presence` sibling of the push envelope;
990
- `server.fetch`'s push lane forwards it whole to `session.setPresence(presence)`.
991
- The API mirrors the wire.
992
-
993
- The handler-scoped form `ctx.session.setPresence(...)` is the same
994
- operation; `seen` defaults to the triggering event's `index`.
995
-
996
- ### `session.presence()`
997
-
998
- ```ts
999
- session.presence(): Promise<{
1000
- [participant: string]: {
1001
- [field: string]: { value: unknown; seen: number; at: Date }
1002
- }
1003
- }>
1004
- ```
1005
-
1006
- The current presence map, expired values pruned. A point-in-time read
1007
- for server renders; live consumers use `stream({ presence: true })`.
1008
- Each entry's `at` is the sender's stamp for when the value was set,
1009
- the last-writer-wins comparator; how long a value stays painted is
1010
- still view logic, decided at render time.
1011
-
1012
- ## Event
1013
-
1014
- ```ts
1015
- type Event = {
1016
- id: string
1017
- type: string
1018
- payload: unknown // typed by the contract's schema for `type`
1019
- index: number // position in the session's log, from 1
1020
- sessionId: string
1021
- createdAt: Date
1022
- }
1023
- ```
1024
-
1025
- ## `experimental-a2/react`
1026
-
1027
- ### `createReact(options)`
1028
-
1029
- ```ts
1030
- createReact(options: {
1031
- client: A2Client // bind a shared client: api + contract + session identity
1032
- }): { SessionProvider, useSession }
1033
-
1034
- // or, bind the contract and supply api through the Provider:
1035
- createReact(options: {
1036
- contract: Contract // requires a public reducer
1037
- }): { SessionProvider, useSession }
1038
- ```
1039
-
1040
- A factory, like `createContext`: call it once in a `'use client'`
1041
- module, export the bound pair. Server components import
1042
- `SessionProvider` from that module as a client reference; client
1043
- components import `useSession` from the same file. See
1044
- [Live UI](/guides/react#bind-react-once).
1045
-
1046
- `createReact({ contract })` is also supported for provider-bound clients. Use a
1047
- shared `A2Client` when code outside the provider needs to push before a route
1048
- transition and the destination should adopt the same optimistic session.
1049
-
1050
- ### `<SessionProvider>`
1051
-
1052
- | Prop | What it is |
1053
- | --------------- | ------------------------------------------------------ |
1054
- | `sessionId` | which session to subscribe to |
1055
- | `initial` | optional snapshot or Promise of a snapshot; `null` means bootstrap |
1056
- | `initialEvents` | optional earlier raw events for a history UI |
1057
- | `participant` | overrides the client's `participant`; one of the two is required to call `setPresence` |
1058
-
1059
- Supplies scope and optional initial data. Mounted enabled hooks own
1060
- connection leases and reconnect from the last streamed event. `participant` binds at the session's
1061
- first resolution; a changed provider prop is ignored for the session's
1062
- runtime lifetime.
1063
-
1064
- `initial` carries the complete state/index snapshot, plus `clearIndex` when
1065
- the session has been cleared.
1066
- For a new session, the stream starts after `initial.index`, including events
1067
- appended between the server render and the connection. `initialEvents` only preloads the raw
1068
- `events` feed for a UI that needs entries from before that frontier. It does
1069
- not affect state hydration or stream resumption. Later snapshots advance
1070
- state while preserving the stream's resume position, so delayed events
1071
- still enter the feed without folding into state again.
1072
-
1073
- `initial` accepts `InitialSnapshot<S>`: `StateSnapshot<S> | null | undefined |
1074
- Promise<StateSnapshot<S> | null | undefined>`. The type is exported from
1075
- `experimental-a2` and `experimental-a2/client`. A cold session waits for a
1076
- supplied Promise before connecting. A snapshot result seeds state; `null`
1077
- or `undefined` selects subscription bootstrap. Rejection pauses cold startup
1078
- and reports through `connection.error`; supply a new Promise, snapshot, or
1079
- `null` to resume. A warm session keeps its current state and connection.
1080
- The Provider itself does not suspend. Keep Promise identity stable.
1081
-
1082
- ### `useSession(options?)`
1083
-
1084
- ```ts
1085
- const { state, hydrated, push, reconnect, events, index, loadHistory, history, connection } = useSession()
1086
- ```
1087
-
1088
- `state` has the reducer's client state type. A supplied snapshot seeds the
1089
- first render. Otherwise it displays `reducer.initialState`, through the client
1090
- projection when present, until a snapshot replaces it atomically. Without a
1091
- supplied snapshot, the server sends one first on the same subscription.
1092
- Bootstrap errors and retries use `connection`.
1093
- `hydrated` is a boolean shared with `session.getSnapshot()`. It is `false`
1094
- while only the reducer seed is available and becomes `true` when an initial
1095
- snapshot or bootstrap is applied, even for an empty state at index 0.
1096
- It stays `true` while cached state survives reconnects, disabled hooks, or a
1097
- failed replacement preload. It does not imply a live connection or fresh data.
1098
- The hook accepts `sessionId`, `client`, `participant`, `initial`, `enabled`, and
1099
- `suspend` options; scope defaults come from its Provider and bound client.
1100
- `suspend` defaults to `false`. With `true`, React `use()` reads the supplied
1101
- `initial` Promise, using the nearest Suspense and Error boundaries. Automatic
1102
- subscription bootstrap never suspends, including when the Promise resolves
1103
- to `null` or `undefined`. The default renders the reducer seed while a cold
1104
- initial Promise is pending. `enabled` controls connection ownership independently.
1105
-
1106
- `state` starts from that snapshot and folds live events through the
1107
- shared reducer. `events` is the raw event feed: observed, explicitly
1108
- seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
1109
- `loadHistory` fetches them, it begins after `initial.index`. `index` is
1110
- the latest server-confirmed state index, the `lastSeenIndex` for
1111
- [cancellation](/guides/cancellation).
1112
-
1113
- `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
1114
- slice of the log from below the frontier (the same route, `gte`/`lte`
1115
- query parameters) and merges it into `events`, deduped, ordered, and
1116
- shared across every handle of the session. It resolves with the events
1117
- in the requested range. Defaults walk backward 50 at a time from the
1118
- oldest loaded event; `before` is an exclusive upper bound. After a
1119
- newer snapshot hydrates the session, default paging still continues from
1120
- the oldest loaded event. Pass an explicit `before` to read another range.
1121
- It never touches `state` or the optimistic overlay. Calls
1122
- serialize per session and already-loaded ranges are not refetched.
1123
- `history` is the progress: `{ loading, complete, oldestLoaded }`, where
1124
- `complete` means the feed reaches index 1, or the first event after the latest
1125
- clear (or the log is empty). The `ws` api has no history lane; there `loadHistory`
1126
- throws a `TypeError`.
1127
-
1128
- When a clear reaches the session, `state` resets to the reducer's initial
1129
- state and `events` drops everything at or below the marker. Unconfirmed pushes
1130
- stay in the overlay and apply to the reset state. A push acked below the marker
1131
- was cleared with it: it leaves the overlay and its `confirmed` resolves.
1132
-
1133
- `presence` is the replicated ephemeral map,
1134
- `Record<participantId, { [field]: { value, seen, at } }>`, including
1135
- this client. `setPresence(values)` is fire-and-forget: validated
1136
- locally, merged into the local map immediately, coalesced on the wire
1137
- to a fixed cadence (a leading send immediately, then at most one
1138
- merged send per interval; later values win per field), stamped with
1139
- the frontier at send time as `seen`. Parallel sends are safe: merges
1140
- are last-writer-wins by `at`, and the client's stamps are monotonic
1141
- per session (wall-seeded, guarded so overlapping sends can never
1142
- invert). No ack, no `confirmed`, no retry.
1143
-
1144
- Both members exist only when the contract declares `presence`; their
1145
- value and field types come from its schemas, through the reducer, with
1146
- no type arguments (the reducer is the client's typed handle on the
1147
- contract; it still never folds presence). `setPresence` requires a
1148
- `participant`: the client's default, or the provider's override. See
1149
- [Presence](/guides/presence).
1150
-
1151
- `push` appends optimistically: validated locally, rolled back on
1152
- rejection, retried for `STORE_UNAVAILABLE` and transport failures. Awaiting it gives the
1153
- server ack; the same result carries `confirmed`, a lazy promise for
1154
- the later moment when the live stream has delivered the batch back and
1155
- the view shows server truth:
1156
-
1157
- ```ts
1158
- const t0 = performance.now()
1159
- const result = push({ type: 'created', payload })
1160
- await result // the server ack: push→ack latency
1161
- await result.confirmed // the stream round-trip: ack→stream latency
1162
- const roundtripMs = performance.now() - t0
1163
- ```
1164
-
1165
- Overlapping calls through the same session client update the view
1166
- immediately, then enter the transport in invocation order. Each call waits
1167
- only for the previous call's ack or final rejection, not for `confirmed`.
1168
- Other session IDs and clients remain concurrent; they converge on the log's
1169
- durable order.
1170
-
1171
- A rejected push rejects both promises with the same `A2ClientError`;
1172
- `confirmed` is materialized only when accessed, so ignoring it costs
1173
- nothing. It also reads as intent: `await push(...).confirmed` is
1174
- "continue once this is server truth".
1175
-
1176
- `session.reconnect(): void` immediately restarts a leased subscription,
1177
- including one paused by the reconnection policy,
1178
- from its current cursor. It preserves state, pending writes, and leases, and
1179
- does nothing without an active lease. The React `useSession()` result exposes
1180
- the same `reconnect` function. Use it after a custom REST mutation; it requests
1181
- a restart rather than returning a promise for catch-up completion. A
1182
- multiplexed WebSocket keeps its physical socket and other session lanes;
1183
- a single-session WebSocket replaces its socket.
1184
-
1185
- `connection` is a discriminated union; impossible states are
1186
- unrepresentable (an `error` only exists while disconnected):
1187
-
1188
- ```ts
1189
- // in a client module:
1190
- import type { A2ClientError } from 'experimental-a2/client'
1191
-
1192
- type Connection =
1193
- | { status: 'idle' }
1194
- | { status: 'connecting'; reconnects: number; error: A2ClientError | null }
1195
- | { status: 'paused'; reconnects: number; error: A2ClientError | null }
1196
- | { status: 'live'; reconnects: number }
1197
- | { status: 'closed' }
1198
- ```
1199
-
1200
- "Reconnecting…" is `status === 'connecting' && reconnects > 0`. And
1201
- it's honest about silence: the server heartbeats the stream (`: ping`
1202
- every 15s), and a client that hears nothing for two beats treats the
1203
- connection as dead (aborts it and reconnects), so `live` means bytes
1204
- are actually flowing, not "the socket hasn't errored yet". See
1205
- [Live UI](/guides/react).
1206
-
1207
- ### `useSession(client, sessionId, options?)`
1208
-
1209
- ```ts
1210
- useSession(client: A2Client, sessionId: string, options?: {
1211
- participant?: string // overrides the client's participant
1212
- initial?: InitialSnapshot<S>
1213
- enabled?: boolean
1214
- suspend?: boolean
1215
- }): UseSessionResult
1216
- ```
1217
-
1218
- The standalone, provider-less hook. It returns the same result shape
1219
- as the bound hook, for apps whose components reach sessions ad hoc (a
1220
- sidebar of channel sessions, a user session read from a menu). The
1221
- handle is identity-mapped: every hook and provider mounting the same
1222
- session shares one runtime. The stream is refcounted: the first mount
1223
- connects, the last unmount closes (StrictMode-safe).
1224
-
1225
- `initial` accepts the result of `session.state(reducer)` or its Promise. Pass it
1226
- whole: its `clearIndex` lets a cached client drop history from before a clear
1227
- it never streamed. The automatic snapshot carries it too. Without it,
1228
- the subscription obtains a snapshot automatically. To defer automatic I/O,
1229
- pass `enabled: false`. Disabled hooks keep observing cached state; other
1230
- owners continue normally. Explicit pushes can still acquire a connection.
1231
- For deliberate full replay, supply `{ state: reducer.initialState, index: 0 }`.
1232
- See [Live UI](/guides/react).
1233
-
1234
- ## `experimental-a2/ai`
1235
-
1236
- The isomorphic AI protocol: event schemas, the `AIState` reducer, pure input
1237
- builders, and `agentContract()` for extending both. It has a peer dependency on
1238
- AI SDK v7, used as types only here.
1239
-
1240
- ### `agentContract(options)`
1241
-
1242
- ```ts
1243
- agentContract<M extends UIMessage = UIMessage>(options: {
1244
- name: string
1245
- events?: Record<string, StandardSchemaV1>
1246
- messageSchema?: StandardSchemaV1<M>
1247
- reducer?: {
1248
- name: string // snapshot identity: rename it when fold changes
1249
- initialState: X // its keys may not shadow AIState's
1250
- fold: (state: X, event: ContractEvent) => X
1251
- }
1252
- }): Contract<Events, {}, AgentReducer<M, Events, X>>
1253
- ```
1254
-
1255
- A core contract carrying the built-in AI protocol plus `events`, with a reducer
1256
- that folds `AIState` and, with `reducer`, your application state beside it.
1257
- The session's state is `AIState<M> & X`. Share it when a client needs
1258
- application events or state: serve it with `agent({ contract })` and use it
1259
- with [`useAgent({ api, id, contract })`](#useagentoptions). Otherwise
1260
- `agent({ name })` builds one on the server and clients use the built-in
1261
- protocol.
1262
-
1263
- Built-in event names cannot be replaced by `options.events`. `messageSchema`
1264
- validates a narrower `UIMessage` type in message payloads; the explicit type
1265
- argument types messages without one. `reducer.fold` sees `X` and every event,
1266
- never the transcript; it runs after the AI fold on the server and in every
1267
- client, so optimistic display carries through. A key of `initialState` that
1268
- `AIState` also has throws a `TypeError` and fails to typecheck. The reducer name
1269
- is `AI_STATE_REDUCER` (`a2.ai.state.v26`) without `reducer`, and
1270
- `${reducer.name}+a2.ai.state.v26` with one, so checkpoints advance when either
1271
- fold changes. `AgentReducer` types that name, which is what lets
1272
- [`createAgentClient()`](#createagentclientoptions) accept a plain
1273
- `agentContract()`, and carries `X` to `agent()`'s resolvers.
1274
-
1275
- ### `TranscriptMessage<M>`
1276
-
1277
- ```ts
1278
- type TranscriptMessage<M extends UIMessage> = Omit<M, 'parts'> & {
1279
- parts: Array<M['parts'][number] | CompactionPart>
1280
- }
1281
- ```
1282
-
1283
- The message type of `AIState<M>['messages']` and `deriveUIMessages()`: your
1284
- message type with the [compaction part](#iscompactionpartpart) added, so
1285
- `isCompactionPart()` narrows even when `M` declares its own data parts.
1286
-
1287
- ### `AIMessageOf<Events>`
1288
-
1289
- The `UIMessage` type a contract's `ai.message.created` schema carries, such as
1290
- `AIMessageOf<typeof contract.events>`. It falls back to `UIMessage`. `agent()`
1291
- uses it to type messages for any contract carrying the AI protocol.
1292
-
1293
- ### `handlerContext(contract?)`
1294
-
1295
- Reads the durable A2 handler context ambient to the current tool
1296
- execution. Tools are plain AI SDK `tool()` definitions; A2 passes their
1297
- options through to the AI SDK without interpreting them and runs each
1298
- local `execute` inside an ambient context scope. Call
1299
- `handlerContext()` anywhere below `execute`, including awaited helpers and
1300
- async iteration. A call outside any tool execution throws a `TypeError`.
1301
- Without an argument, the context is typed for the built-in protocol. Pass a
1302
- shared `agentContract()` to type application events; A2 verifies it against the
1303
- executing agent and throws a `TypeError` on a mismatch.
1304
-
1305
- The returned `AgentToolContext<D>` supplies the current A2 handler's `event`,
1306
- `attempt`, `session`, and `signal`. Local tools execute the explicit
1307
- `ai.tool.execution.requested` work event. Its payload contains the admitted
1308
- `call`, `generation`, `turnId`, and execution `version`. Calls such as
1309
- `ctx.session.schedule()` use the scheduler of the agent executing the tool.
1310
-
1311
- The context is runtime capability, not event data. A2 creates it for the
1312
- current attempt, never persists it, and never sends it to the model or a
1313
- provider-executed tool. Each retry reads that attempt's fresh context.
1314
- Model-phase tool options (`contextSchema`, dynamic descriptions,
1315
- `needsApproval` predicates, input lifecycle callbacks) are the AI SDK's
1316
- own; A2 forwards them untouched, and durable execution passes no AI SDK
1317
- `context` option. `needsApproval` and `generation.toolApproval`
1318
- predicates run during the model step and are re-evaluated on recovery
1319
- for the same durable tool call, so keep them deterministic over the tool
1320
- input. Model-phase hooks see tool definitions without local `execute`
1321
- functions.
1322
-
1323
- ### `inputs`
1324
-
1325
- Pure typed inputs for `append()` and `push()`. The `useAgent()` actions build
1326
- the browser ones:
1327
-
1328
- | Input | Events |
1329
- | --- | --- |
1330
- | `inputs.message(message, { generate? })` | queues input; `generate: false` records passive context at admission |
1331
- | `inputs.seed(message)` | queues passive context; non-user roles require a trusted append |
1332
- | `inputs.approval(response)` | answers a pending tool approval |
1333
- | `inputs.input(response)` | answers a pending application input |
1334
- | `inputs.requestInput(request)` | requests application input from trusted server code |
1335
- | `inputs.retry(options)` | retries a failed response |
1336
- | `inputs.stop({ turnId, lastSeenIndex? })` | ends the observed turn and advances when admission is open |
1337
- | `inputs.steer({ turnId, message, lastSeenIndex? })` | puts the new input first for a handoff after the observed turn's current step |
1338
- | `inputs.queue.edit({ message })` | changes a pending input; its ID stays fixed |
1339
- | `inputs.queue.move({ inputId, beforeId })` | moves pending input before an ID; `null` means the end |
1340
- | `inputs.queue.remove({ inputId })` | removes pending input; its submission stays in the log |
1341
- | `inputs.queue.sendNow({ inputId, turnId, lastSeenIndex? })` | promotes an existing queued input for a handoff after the current step; preserves pause |
1342
- | `inputs.pause({ when: 'now' \| 'after-turn' })` | pauses execution now or inbox admission after the turn |
1343
- | `inputs.resume()` | resumes retained work, then inbox admission |
1344
- | `inputs.toolResult(result)` | records a deferred provider-executed tool result from trusted server code |
1345
- | `inputs.interrupt(options)` | interrupts a specific generation/request owner |
1346
-
1347
- Edits are last-write-wins in session log order and preserve the pending message's
1348
- identity and role. They require no revision or confirmation before another edit.
1349
- Admission freezes the message; subsequent edits return `not-pending`.
1350
-
1351
- All builders emit `ai.control.requested`. Persistence and application are
1352
- separate: `ai.control.decided` returns `{ commandId, outcome, reason? }` through
1353
- the existing stream. Outcomes are `applied` or `rejected`. Rejection reasons
1354
- include `not-pending`, `role-mismatch`, `stale-turn`, `not-found`,
1355
- `not-user-input`, and `closed`.
1356
- `state.receipt` holds the latest user receipt.
1357
-
1358
- Steering validates and prioritizes new input even if the observed turn has
1359
- finished. A matching active turn finishes its current model response and tool
1360
- batch, then yields before another model step. Running local workers and deferred
1361
- provider results settle first; unanswered human approval/input waits can be
1362
- closed at that point. Pause stays closed. Otherwise the input starts when idle
1363
- or waits first behind a different active turn. The priority change is atomic;
1364
- the later handoff and new work are committed atomically at the step boundary.
1365
- Steer and Send now do not use `lastSeenIndex` to cut output.
1366
-
1367
- Queue removal leaves the original submission in the log, so replay does not recreate the input.
1368
- Send now preserves the queued input ID and latest accepted content. It requires
1369
- an observed `turnId` (or null for idle), preserves pause, and only accepts
1370
- user-role input. It promotes passive user input to generation. Missing targets
1371
- and stale turns reject before any queue change.
1372
-
1373
- `state.inbox` contains `paused` and ordered `items`. Each item contains
1374
- `{ id, message, generate, afterStepOf? }`. The optional turn ID
1375
- `afterStepOf` records the observed steering turn while that item remains queued;
1376
- a matching active turn hands off after its current step.
1377
- Removal cancels it and admission consumes it. `state.active` is null or contains
1378
- `{ turnId, input, phase }`; admission freezes `input`. On the server, pending
1379
- inputs are absent from `messages` and model context. The `paused` status and the `pausing`/`paused`
1380
- active phases represent retained work. Resume uses a fresh model request and
1381
- execution version. Tools without terminal outcomes can retry; their stable
1382
- `toolCallId` supports external idempotency.
1383
-
1384
- `inputs` deliberately has no session lifecycle methods. Append explicit
1385
- `ai.session.created` and `ai.session.closed` events from trusted server code
1386
- when an application uses them. Message, seed, and steering builders set the event ID to the input message ID.
1387
- The two IDs must match for all new-input commands. Caller command/input IDs must
1388
- be nonempty and cannot use the reserved `a2.ai:` prefix. Identical events replay;
1389
- conflicting ID reuse rejects before append under core deduplication rules. IDs
1390
- are unique across the store, including different contracts and sessions.
1391
- Approval, input-response, and retry builders use stable interaction IDs.
1392
- Other commands use IDs assigned by `push()` or `append()`; transport retries
1393
- preserve those IDs. Browser
1394
- ingress accepts user messages, approval and input responses, interruptions,
1395
- and explicit retries. Only built-in server handlers append generation requests
1396
- and AI lifecycle events. `inputs.requestInput()`, `inputs.toolResult()`, and non-user messages built
1397
- with `inputs.seed()` are for trusted server appends.
1398
-
1399
- `inputs.message(message, { generate: false })` is valid browser input. It admits
1400
- the user message to `AIState.messages` without starting a model turn. The next
1401
- user message that allows generation includes passive user messages before it
1402
- in model context. Passive user messages after that trigger wait for a later
1403
- generating message. Omitting the option preserves the default scheduling
1404
- behavior. `inputs.seed()` writes
1405
- the same passive flag. Trusted assistant and system seeds are context, not user
1406
- queue cutpoints. The server never schedules them, regardless of the `generate`
1407
- field. Model output is generation progress rather than another
1408
- `ai.message.created` fact, so an assistant response cannot recursively start a
1409
- new turn.
1410
-
1411
- Approval and input request/response payloads require the active
1412
- `requestId` and `generationId`. Clients copy both from the pending request, which prevents a
1413
- delayed response from satisfying a newer model step.
1414
-
1415
- `ai.retry.requested` carries `{ messageId, responseMessageId, retryId }`.
1416
- `retryId` identifies one user action. The server converts the fact into an
1417
- `ai.generation.requested` whose reason is `retry`.
1418
- Retry reruns the failed model call while retaining earlier completed model steps,
1419
- their tool results, and committed compactions, including steps completed after
1420
- the latest compaction.
1421
- The failed model call's uncommitted step is removed from the active prompt and
1422
- response. Conversation forks and edit-and-resend flows for admitted messages are
1423
- application-owned.
1424
-
1425
- A2-generated requests, generations, assistant responses, worker jobs, reports,
1426
- and lifecycle events have fixed-length deterministic IDs in the reserved
1427
- `a2.ai:` namespace. An assistant message's ID equals its initial model-request
1428
- event ID. Control state keeps no lifetime input/response-ID registry. IDs are opaque;
1429
- ownership is carried by event fields. `ai.generation.requested` requires
1430
- `responseMessageId`; a tool continuation also requires `sourceGenerationId`.
1431
-
1432
- `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
1433
- continuation before another model step starts.
1434
-
1435
- `ai.message.interrupted` carries either `{ messageId, requestId, reason?,
1436
- lastSeenIndex? }` or `{ messageId, generationId, reason?, lastSeenIndex? }`.
1437
- Exactly one owner ID is required. Clients copy `requestId` from
1438
- `activeRequestId` before generation start and switch to the observed
1439
- `activeGeneration.generationId` after start. A request-owned interruption
1440
- remains valid if that same request starts before the append commits. A delayed
1441
- interruption for an older owner is a projection and cancellation no-op.
1442
- The message and owner IDs are validated as one pair. If either ID does not
1443
- identify that same response owner, the interruption is stale and is a no-op.
1444
-
1445
- An accepted interruption terminally fences its request or generation. Later
1446
- generation, tool, approval, input, and compaction events remain in raw history
1447
- but cannot alter `AIState`, satisfy pending work, or reactivate the response.
1448
- Completed tool results at or before `lastSeenIndex` remain completed. A final
1449
- tool result that races an ordinary generation failure remains authoritative in
1450
- either commit order, while the generation stays failed.
1451
-
1452
- Interruption and failure projections discard tool parts still in
1453
- `input-streaming`. These argument drafts are not completed tool calls and do not
1454
- enter later model prompts. Raw progress remains in the log. Available inputs,
1455
- approval-waiting calls, and completed results retain their existing terminal
1456
- handling.
1457
-
1458
- ### `reasoningLevels`
1459
-
1460
- ```ts
1461
- type ReasoningLevel = 'provider-default' | 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'
1462
- const reasoningLevels: readonly ReasoningLevel[]
1463
- ```
1464
-
1465
- Every `ReasoningLevel`, in order, for pickers. A `model` option returns one in
1466
- [`ModelSettings`](#modelsettings).
1467
-
1468
- ### `isCompactionPart(part)`
1469
-
1470
- ```ts
1471
- isCompactionPart(part: { type: string }): part is CompactionPart
1472
-
1473
- type CompactionPart = {
1474
- type: 'data-a2-compaction'
1475
- id: string // the compacting generation's ID
1476
- data: {
1477
- status: 'running' | 'completed' | 'failed' | 'interrupted'
1478
- inputTokens?: number
1479
- thresholdTokens?: number
1480
- summary?: string
1481
- usage?: LanguageModelUsage
1482
- }
1483
- }
1484
- ```
1485
-
1486
- The transcript part that marks a compaction. When `ai.compaction.requested`
1487
- folds, the reducer appends it to the active response, between the steps
1488
- before it and the model step it compacted for; a compaction before the first
1489
- step creates the response with this part. The part counts toward that step's
1490
- recovery base, so Stop cutoffs, failures, and retries of the step keep it.
1491
- `ai.compaction.completed` settles it in place with the summary and usage. A
1492
- running compaction whose generation ends first becomes `interrupted` (Stop,
1493
- Pause now, or session close) or `failed` (failure). A pending Stop previews it
1494
- as `interrupted` on the client. A superseded worker attempt's part is removed,
1495
- and its replacement compacts again if it needs to. AI SDK
1496
- `convertToModelMessages()` skips data parts by default, and model context never
1497
- contains it.
1498
-
1499
- ### `events` and `createEvents(options?)`
1500
-
1501
- The built-in Standard Schema definitions. Use `events` for the default AI SDK
1502
- `UIMessage`; use `createEvents({ messageSchema })` to validate a more specific
1503
- message type. Both are isomorphic.
1504
-
1505
- ### Optimistic AI client state
1506
-
1507
- Every client folding an AI reducer exposes `state.messages`, `state.inbox`, and
1508
- `state.starting` with pending send, edit, move, remove, Send now, steer, pause,
1509
- resume, and Stop commands applied for display. This includes contracts with
1510
- application state from `agentContract({ reducer })`. Idle, unpaused
1511
- sends appear in the conversation immediately. A generating input occupies the
1512
- optimistic turn, so subsequent sends queue. Busy or paused sessions queue new
1513
- sends. `starting` indicates a speculative turn awaiting acceptance; it does not
1514
- indicate model execution. Confirmed turns take precedence during reconciliation.
1515
- Steering inputs, and Send now inputs targeting an active turn, appear in
1516
- `state.messages` after the current response and are omitted from the client
1517
- inbox. This display survives receipt and hydration, including while paused.
1518
- They remain pending server inputs until admission and do not enter the model
1519
- prompt early. Multiple steering inputs follow inbox admission order.
1520
-
1521
- New active turn identities come from accepted control decisions. A pending Stop
1522
- for that turn clears the displayed `state.active`, generation, and pending
1523
- approvals/inputs, and previews an idle status (or paused when the inbox is paused). With
1524
- `lastSeenIndex`, its retained generation projection freezes at the observed
1525
- frontier. The preview lasts through the request echo until the controller's
1526
- decision; rejected requests restore the confirmed active turn and output.
1527
- Server reads and reducer folds retain confirmed messages and inbox entries;
1528
- `state.starting` is false there. Custom reducers that call `reduceAIState()` or
1529
- nest the AI fold do not inherit the client display behavior. Repeat editing and
1530
- Send now remain available while earlier edits are pending. Later local edits
1531
- stay visible through earlier commands' acknowledgments and receipts.
1532
-
1533
- The pending-command ledger survives request echo and retires on the matching
1534
- `ai.control.decided` receipt or session close. Failed pushes roll back through
1535
- the client's existing optimistic overlay. This display adds no I/O and does not
1536
- read raw history.
1537
-
1538
- ### `AIState`
1539
-
1540
- The AI reducer's `initialState`, `fold`, server `session.state()`, and client
1541
- snapshots all use `AIState`. Keep state and its log index together when
1542
- hydrating; do not fold already-seen history twice. `messages` holds
1543
- [`TranscriptMessage<M>`](#transcriptmessagem) values. Model context has its own
1544
- built-in fold and schema, which carries the contract's application state.
1545
-
1546
- Persistence encoding is internal. Tool inputs, outputs, and provider metadata
1547
- live in message parts; the tool activity list retains only identity and status.
1548
- No event or tool payload is truncated. `AIState`
1549
- contains `inbox`, `active`, `starting`, the latest command `receipt`, session lifecycle,
1550
- messages, generation status, pending approvals
1551
- and input, tool activity, compaction, usage, the last error, and
1552
- `activeRequestId`, `activeRequestReason`, `activeRequestSourceGenerationId`, `activeResponseMessageId`,
1553
- `responseOwners: Record<string, { generationId: string; requestId: string; attempt: number }>`, and terminal request and
1554
- generation ownership fences.
1555
- `activeRequestId` is the server-authorized generation request and fences
1556
- delayed requests before their generation starts. `activeResponseMessageId`
1557
- identifies the requested response until `activeGeneration` exists.
1558
- `activeProjection` holds the indexed generation frontier while a generation is
1559
- active, after a generation step completes while its response waits on tool,
1560
- approval, or input barriers, while paused, and after a generation fails while it awaits retry
1561
- or interruption. Response completion, interruption, supersession, retry, or a
1562
- later generation clears or replaces it. `responseOwners` keeps the
1563
- latest generation owner for each response message, so late events from a
1564
- superseded owner cannot alter the projection. `terminalRequestIds` and
1565
- `terminalGenerations` preserve accepted completion, failure, interruption,
1566
- and supersession fences across snapshots and recovery. They are optional
1567
- snapshot-compatible fields with the shapes `Record<string, true>` and
1568
- `Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
1569
- The active projection also stores its prompt boundary and a serializable stream
1570
- cursor for open text, reasoning, and tool-input parts. Ordered progress applies
1571
- only its new chunks. `activeProjection.batches` stores indexed batches as
1572
- `{ length, tail, blocks }`: a bounded tail and shared completed blocks. Appending
1573
- ordered progress does not copy the full retained batch list. The blocks are
1574
- ordinary JSON with logarithmic nesting depth, so checkpoints can be serialized
1575
- and cloned. Retained batches support exact interruption cutoffs and out-of-order
1576
- reconstruction. The cursor and blocks share the active projection's lifetime.
1577
- Checkpoint size and serialization cost still grow with retained progress.
1578
- Tool-input deltas update `input-streaming` parts with best-effort partial JSON
1579
- for display. The state stays `input-streaming` until a completed input arrives;
1580
- partial parsing does not authorize execution or change interruption handling.
1581
- Application events are ignored by the AI fold.
1582
-
1583
- ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
1584
-
1585
- `deriveUIMessages()` folds a raw A2 event history directly into
1586
- `TranscriptMessage<M>[]`, AI SDK messages that include compaction parts. `reduceAIState()` is the event-at-a-time form used by the
1587
- standard reducer. Both synchronously project the same chunk-only protocol.
1588
-
1589
- ## `experimental-a2/ai/server`
1590
-
1591
- ### `agent(options)`
1592
-
1593
- ```ts
1594
- agent({
1595
- name: string, // or contract: agentContract(...), never both
1596
- events?, messageSchema?, // with name: server-only application events
1597
- model: LanguageModel | ModelSettings | (context => LanguageModel | ModelSettings),
1598
- tools?,
1599
- instructions?,
1600
- generation?,
1601
- maxSteps?,
1602
- messageMetadata?,
1603
- generate?,
1604
- compaction?,
1605
- progress?,
1606
- toolLane?: string, // run local tool executions one at a time on this lane
1607
- handlers?, // application event handlers
1608
- store?, scheduler?, telemetry?,
1609
- }): Agent<Events, State, Name> // an A2Server
1610
- ```
1611
-
1612
- Serves an AI agent as one ordinary A2 server: the built-in protocol, its model
1613
- and tool handlers, and A2's store, scheduler, and telemetry. Pass `name` for the
1614
- built-in protocol (with optional server-only `events` and `messageSchema`), or a
1615
- shared `agentContract()` as `contract` when clients fold application state.
1616
- Any other contract throws a `TypeError` and fails to typecheck; a fully custom
1617
- contract is core A2, served by `createServer()`. Messages are typed by the
1618
- contract's `ai.message.created` schema through
1619
- [`AIMessageOf`](#aimessageofevents).
1620
- The result is an `A2Server` with `fetch`, `session(id)`, `drain()`, and
1621
- `contract`. `typeof` it types `useAgent()` and
1622
- `createAgentClient()` through a type-only import; `Name` is its reducer name,
1623
- which decides whether the contract-free client accepts it.
1624
-
1625
- A2 runs one AI SDK `streamText()` model step per durable generation, persists
1626
- its `UIMessageChunk`s once in durable progress batches, projects messages
1627
- synchronously, extracts tool and approval lifecycle events, executes local
1628
- tools, and records completion, usage, interruption, and failure. `model`
1629
- accepts an AI SDK model string or provider model, or
1630
- [`ModelSettings`](#modelsettings) to choose its reasoning level and speed with
1631
- it. `model`, `instructions`, and `generation` can be values or per-generation
1632
- async resolvers. A resolver that throws, or returns an invalid value, fails the
1633
- turn once with its message, like a provider error, instead of retrying; the
1634
- attempt's `ai.generation.started` omits `model` when the model never resolved.
1635
- `retry()` runs the step again. Store failures (`A2Error`) still retry durably.
1636
-
1637
- `fetch` accepts reads and pushes of public AI controls:
1638
- `ai.control.requested` commands other than the server-authored
1639
- `request-input` and `tool-result`, with
1640
- `send`, `edit`, and `steer` limited to user messages. With a `contract`, it also
1641
- accepts the contract's application events, like any A2 server; `events` passed
1642
- with `name` stay server-only, since no client can push them. Every other pushed
1643
- event answers `400`. `fetch(request, { authorize })` adds per-operation policy on
1644
- top, such as who may push which application event. `handlers` handles application events; naming an
1645
- event the agent handles itself throws a `TypeError` at construction.
1646
-
1647
- The agent's `telemetry` instruments both Core and AI operations in the same
1648
- active trace. See the [span catalogue](#the-span-catalogue).
1649
-
1650
- `generation` contains per-step settings such as `temperature`,
1651
- `maxOutputTokens`, `topP`, provider options, and tool approval policy. A2 owns
1652
- the one-step stop condition, local tool execution, and continuation. Responses
1653
- have no step limit by default. Set `maxSteps` to a positive integer to limit one
1654
- complete assistant response. Support for individual model settings depends on
1655
- the selected model and provider. `generation` excludes `stopWhen`, tool
1656
- execution callbacks, tool callers, sandbox execution, and the tool approval
1657
- secret. Everything else, including `toolsContext`, passes through to the AI SDK
1658
- untouched. A local tool reads its durable context with `handlerContext()` when
1659
- its handler executes. Model-step timeouts remain available; tool-execution
1660
- timeouts are not.
1661
-
1662
- A `generation` resolver receives `{ event, messages, state, session, signal }`
1663
- and returns `AgentGenerationSettings` or a promise. It runs once per attempt
1664
- before compaction and model dispatch; its settings also govern tool approvals
1665
- and automatic-compaction compatibility. Static settings are validated at
1666
- construction, resolved settings before use. Failures and cancellation follow
1667
- model resolution.
1668
-
1669
- `messageMetadata(context)` synchronously maps each default AI SDK stream part
1670
- to the metadata type of the agent's `UIMessage`. It receives the same context
1671
- as `generate(context)`, plus the current `TextStreamPart` as `part`; its
1672
- `generation` is the settings the default generator sends, with A2's Gateway
1673
- defaults applied. Metadata returned for standard `start` and `finish` parts is
1674
- recorded with generation progress and shallow-merged onto the projected
1675
- assistant message. Use the `finish` part's `totalUsage` to retain final token
1676
- usage. A custom `generate` function owns its metadata chunks and does not use
1677
- this mapper.
1678
-
1679
- `generate(context)` optionally replaces the default AI SDK generation. It
1680
- receives the resolver context, `{ event, messages, state, session, signal }`,
1681
- plus the step's `generationId`, `modelMessages`, and the resolved `model`,
1682
- `instructions`, `tools`, and `generation` settings. `messages` and
1683
- `modelMessages` reflect this step's compaction. Its stream writes the assistant
1684
- message `event.payload.responseMessageId`. It returns exactly one model step as
1685
- a `ReadableStream<UIMessageChunk>`. A2 continues to own tool execution,
1686
- continuation, and the durable lifecycle around that stream.
1687
-
1688
- Generation requests use one durable lane, so model calls for a session do not
1689
- overlap. Queued-turn policy also waits to schedule a later user message until
1690
- the active assistant response, including its complete tool loop, reaches a
1691
- terminal event. The policy matters in addition to lane FIFO because a tool
1692
- continuation may be appended after the later user message. A generation
1693
- failure keeps later messages queued until the failed response is explicitly
1694
- retried or interrupted.
1695
-
1696
- Independent authorized tool handlers run concurrently without a fixed
1697
- concurrency limit. `toolLane` names a lane for every local tool execution, so
1698
- they run one at a time in admission order; model steps keep their own lane. A private coordinator reducer tracks generation closure,
1699
- cancellation, calls, approvals, and terminal results for the active response.
1700
- Completed responses do not accumulate in its state. Its
1701
- `ctx.session.state(coordinator, { through: 'latest' })` reads the durable
1702
- snapshot plus log tail because these joins intentionally observe concurrent
1703
- tool results. Process memory is not authoritative. Concurrent join checks return
1704
- the same deterministic continuation event, so they use `ctx.session.append()`
1705
- and storage deduplicates the race.
1706
-
1707
- Tool results and generation completion have no fixed relative order. The
1708
- continuation predicate needs both the closed model step and every required
1709
- terminal result.
1710
-
1711
- Single-owner terminal events use returned-event causality. They commit
1712
- atomically with parent completion and receive the parent's durable cause.
1713
- Many-owner joins and preliminary streaming outputs use immediate idempotent
1714
- appends. Provider calls and external tool side effects are at least once and
1715
- require their own idempotency. Approved provider-executed calls may satisfy the
1716
- join from the approval response. Provider tools with declared deferred-result
1717
- support wait for their provider result instead of a local executor. An
1718
- authenticated provider callback appends the terminal `ai.tool.result` through
1719
- the trusted server session API; `fetch` rejects it from browsers.
1720
-
1721
- `compaction` defaults to automatic for supported Gateway requests. It accepts
1722
- `false` to disable compaction and discovery, `{ thresholdTokens?, instructions? }`
1723
- to override automatic behavior, or the existing custom
1724
- `{ shouldCompact(context), compact(context) }` policy. It also accepts a
1725
- resolver `(context: AgentResolverContext) => CompactionOptions | Promise<CompactionOptions>`.
1726
- The resolver runs once per generation and returns a valid policy or `false`.
1727
- It can await application reducers through `context.session.state(reducer)`, whose
1728
- default boundary is the captured prompt frontier. Model, instruction, compaction,
1729
- metadata, and custom-generation contexts expose state access and omit raw history.
1730
- Callbacks receive prompt messages and the triggering `event`, without an
1731
- `execution` argument. Model, instruction, and generation-setting resolvers receive
1732
- `{ event, messages, state, session, signal }`, where `state` is the contract's
1733
- application state (from `agentContract({ reducer })`, `{}` without one) as of the
1734
- captured snapshot; it rides in the model-context checkpoint, so it costs no
1735
- read. Compaction, metadata, and custom-generation callbacks receive the same
1736
- context plus `modelMessages`, including committed summaries. The contract's reducer continues to expose the full
1737
- transcript. `context.session.state()` returns its `AIState` directly, paired
1738
- with its log index for hydration.
1739
- Static options are validated at construction; resolved options are validated
1740
- before that generation starts. Each session uses its own resolved value. `thresholdTokens` is an
1741
- optional positive safe integer. `instructions` adds to A2's internal summary
1742
- prompt without replacing the agent's instructions.
1743
-
1744
- Without an explicit threshold, the first generation batches a metadata request
1745
- into its existing start write. An independent handler reads the public Gateway
1746
- catalog and records that session/model's limits. It does not delay model dispatch.
1747
- The default threshold is 75% of the context window, lowered for a larger configured
1748
- output allowance. A pending or unavailable lookup leaves the threshold unknown;
1749
- the first request proceeds, and provider context-limit errors fail normally.
1750
- `state.modelMetadata` retains each model's pending, resolved, or unavailable state.
1751
- Pending also covers exhausted handler retries; no new request is added per turn.
1752
- The catalog has a shared one-hour process cache and a five-second fetch timeout.
1753
-
1754
- Direct/custom providers, fallback model lists, and custom generators need explicit
1755
- compaction configuration. Provider-executed tools, structured output, and forced
1756
- tool choices skip implicit compaction and reject explicit automatic policies.
1757
- Custom policies remain available for these configurations.
1758
-
1759
- The raw input estimate uses serialized bytes of messages, tools, and instructions,
1760
- excluding provider options on messages and content parts. These options still reach
1761
- the model unchanged. It is not a provider tokenizer. A preceding same-model
1762
- completion anchors later growth to reported input usage, correcting both overestimates
1763
- and underestimates. A smaller prompt, retry, compaction, or model change stops
1764
- that calibration.
1765
-
1766
- Completed generations record the raw `inputTokenEstimate` beside usage.
1767
- Automatic `ai.compaction.requested` events also record the pre-compaction
1768
- `inputTokenEstimate`, the calibrated `inputTokens` used for the decision, and
1769
- `thresholdTokens`. Custom-policy requests omit these diagnostics. They add no
1770
- model calls or database round trips.
1771
-
1772
- The built-in summary adds one model request and preserves the request prefix for
1773
- caching. It does not invoke application generation callbacks, transforms, or tool
1774
- hooks. Both custom policy callbacks and `generate` receive `modelMessages`, the
1775
- model-ready prompt including summaries, alongside typed UI `messages`.
1776
-
1777
- Built-in summaries remain separate from application-typed messages. The
1778
- transcript marks each compaction with a
1779
- [`data-a2-compaction` part](#iscompactionpartpart), and
1780
- `state.compaction?.status === 'running'` reports current activity. The
1781
- conversation in `state.messages` remains intact. Model execution
1782
- uses a separate context checkpoint; existing compactions do not require a
1783
- transcript read. Successful compaction is permanent, including across failure,
1784
- interruption, retry, and recovery. Retry reruns the failed step from the latest
1785
- committed context. Compaction defers while tool exchanges or A2-owned tool work
1786
- remain unsettled, checked with one fresh coordinator state read on each candidate.
1787
- Unresolved dead-lettered tool work also defers compaction until repair and
1788
- settlement or session closure. `progress` controls durable batching. By
1789
- default each batch is written as soon as the previous one finishes, so a slow
1790
- model's chunks are written one at a time and chunks that arrive during a write
1791
- share the next batch, up to `maxChunks` (128). `maxDelayMs` (default 0) lets a
1792
- batch keep collecting chunks for up to that long after the previous write;
1793
- boundary chunks and `maxChunks` write sooner. Raising it trades token latency
1794
- for fewer progress events.
1795
-
1796
- This entry point is server-only and resolves to a throwing browser stub.
1797
-
1798
- ### `ModelSettings`
1799
-
1800
- ```ts
1801
- type ModelSettings = {
1802
- model: LanguageModel
1803
- reasoning?: ReasoningLevel
1804
- speed?: 'fast' | 'standard'
1805
- }
1806
- ```
1807
-
1808
- A model with its reasoning level and speed, which a `model` option or resolver
1809
- returns to choose all three together. `reasoning` becomes the AI SDK
1810
- `reasoning` call option. `speed: 'fast'` sets `providerOptions.gateway.speed`
1811
- to prefer AI Gateway's faster tier when the model offers one, and `'standard'`
1812
- removes a configured speed. Both override the same settings in `generation`.
1813
- [`reasoningLevels`](#reasoninglevels) lists the levels.
1814
-
1815
- ### `runWithHandlerContext(ctx, fn)`
1816
-
1817
- The testing seam for context-reading tools: runs `fn` with `ctx`
1818
- installed as the ambient handler context, so a tool's `execute` can be
1819
- called directly in tests. Inside `fn`, `handlerContext()` returns `ctx`
1820
- with or without a contract argument.
1821
-
1822
- See [Durable AI agents](/guides/ai-agents) for the protocol and complete
1823
- examples.
1824
-
1825
- ## `experimental-a2/ai/client`
1826
-
1827
- ### `createAgentClient(options)`
1828
-
1829
- ```ts
1830
- createAgentClient<typeof agent>(options: {
1831
- api: ClientApi
1832
- gcTime?: number
1833
- participant?: string
1834
- reconnect?, fetch?, webSocket?, // as createClient
1835
- }): AgentClient // an A2Client over the built-in AI protocol
1836
- ```
1837
-
1838
- A contract-free client for an `agent()` route. It accepts only agents whose
1839
- reducer carries the built-in name (`typeof AI_STATE_REDUCER`): `agent({ name })`
1840
- or `agent({ contract })` with an `agentContract()` that has no `reducer`. The AI protocol and its
1841
- `AIState` reducer are library code, and the type argument, taken from a
1842
- type-only import of the server module, types messages. Use it for pushes
1843
- outside React, such as a first message sent before navigating, and pass it to
1844
- `useAgent({ client })` so the hook shares its sessions. It pushes the built-in
1845
- AI controls only. Agents built from an extended `agentContract()` use
1846
- `createClient({ contract })` instead, so the client folds the same state.
1847
-
1848
- ## `experimental-a2/ai/react`
1849
-
1850
- ### `useAgent(options)`
1851
-
1852
- ```ts
1853
- useAgent<typeof agent>(options: {
1854
- id: string
1855
- initial?: InitialSnapshot<AIState>
1856
- participant?: string
1857
- } & ({ api: string } | { client: AgentClient })): UseAgentResult
1858
-
1859
- useAgent(options: {
1860
- id: string
1861
- initial?: InitialSnapshot<State>
1862
- participant?: string
1863
- } & (
1864
- | { api: string; contract: Contract } // a shared agentContract(), imported for real
1865
- | { client: A2Client } // createClient({ contract, api })
1866
- )): UseAgentResult
1867
- ```
1868
-
1869
- One agent session in React: its live optimistic `AIState` and actions that
1870
- fill in protocol identities. With `api`, the hook keeps one shared client per
1871
- route for the page's lifetime, reading `globalThis.fetch` on each request; with
1872
- `client`, it uses yours, for a custom `fetch`, headers, or `gcTime`. Both forms
1873
- accept only agents whose reducer carries the built-in name, like
1874
- `createAgentClient()`. With `contract`, it folds the contract's application
1875
- state too, keeping one client per contract and route; `state` is
1876
- `AIState & X` and `push` accepts the contract's events. Pass that contract's
1877
- `createClient({ contract, api })` as `client` instead to share it with pushes
1878
- outside React, such as a first message sent before navigating. A custom
1879
- contract, one not built by `agentContract()`, uses core A2's `useSession()` and
1880
- `inputs` directly. Keep one options form per call site;
1881
- the form must not change between renders.
1882
-
1883
- The result is the session result (`state`, `index`, `connection`, `push`,
1884
- `events`, `hydrated`, `reconnect`, `loadHistory`, `history`) plus:
1885
-
1886
- | Member | Does |
1887
- | --- | --- |
1888
- | `messages` | `state.messages` |
1889
- | `status` | `state.status` |
1890
- | `indicator` | `'starting'`, `'thinking'`, `'compacting'`, or `null` |
1891
- | `sendMessage(message, { generate? })` | queues a string (one text part) or a `UIMessage` |
1892
- | `steer(message)` | steers the active turn after its current step, or sends when idle |
1893
- | `stop()` | stops the active turn at the rendered frontier |
1894
- | `approve(approval)`, `deny(approval, { reason? })` | answers a pending approval |
1895
- | `respond(input, value)` | answers a pending application input |
1896
- | `retry()` | retries the failed response with a fresh `retryId` |
1897
- | `pause({ when })`, `resume()` | pauses and resumes |
1898
- | `queue.edit(message)`, `queue.move({ inputId, beforeId })`, `queue.remove(inputId)`, `queue.sendNow(inputId)` | edits the inbox |
1899
-
1900
- Every action returns a `PushResult`, like `push()`: it resolves to the
1901
- persisted command events and rejects when the push fails, and `.confirmed`
1902
- resolves when the stream delivers them back. Each event's `id` is the
1903
- `commandId` of its `ai.control.decided` receipt. Actions read identities from
1904
- the state of the render that created them, so `stop()` and `queue.sendNow()`
1905
- carry the frontier the user saw. An action with nothing to address, such as
1906
- `stop()` without an active turn or `retry()` without a failed one, resolves to
1907
- an empty array. Actions are rebuilt on every render; their identity is not
1908
- stable.
1909
-
1910
- `indicator` is `starting` for an optimistic turn awaiting acceptance,
1911
- `compacting` while a summary runs, `thinking` while the current model step has
1912
- no visible part (reasoning and step boundaries do not count), and `null`
1913
- otherwise.
1914
-
1915
- ## `experimental-a2/client`
1916
-
1917
- ```ts
1918
- createClient(options: {
1919
- contract: Contract // requires a public reducer
1920
- api: ClientApi
1921
- gcTime?: number // idle session lifetime; 5 minutes by default
1922
- participant?: string // default presence identity for every session
1923
- reconnect?: (failure: {
1924
- sessionId: string
1925
- error: A2ClientError | null
1926
- attempt: number
1927
- }) => number | false
1928
- }): A2Client
1929
-
1930
- type ClientApi =
1931
- | SessionUrl // one route: GET SSE stream + POST push
1932
- | { type: 'http'; push: SessionUrl; stream: SessionUrl }
1933
- | { type: 'ws'; url: SessionUrl; multiplex?: false }
1934
- | { type: 'ws'; url: string; multiplex: true }
1935
-
1936
- type SessionUrl = string | ((sessionId: string) => string)
1937
- ```
1938
-
1939
- `reconnect` runs after a failed connection or a stream ending, including a
1940
- clean end with `error: null`. Return the delay in milliseconds before the next
1941
- attempt, or `false` to pause automatic reconnection. Delays must be finite,
1942
- non-negative, and at most 2,147,483,647 ms. The callback is synchronous; a
1943
- throw or invalid result pauses the connection with an `A2ClientError`.
1944
-
1945
- `attempt` starts at 1 for the first retry. It counts consecutive attempts
1946
- per session and resets when the stream becomes live or `session.reconnect()`
1947
- explicitly restarts it. It is separate from the lifetime `reconnects` count.
1948
- Without a callback, A2 retries indefinitely with delays starting at 500 ms,
1949
- doubling up to five seconds. The callback does not run for an explicit
1950
- restart, an idle-stream refresh, or a caller closing the stream.
1951
-
1952
- While paused, `connection.status` is `paused` and its last error remains
1953
- available. State, hydration, optimistic writes, and connection leases remain
1954
- intact. Another consumer taking a lease does not resume a paused stream.
1955
- Call `session.reconnect()` to resume immediately with a fresh attempt count.
1956
- Releasing the last lease still closes the stream. A later first lease starts
1957
- a new connection. Push retries are independent of this policy; a push does
1958
- not resume a paused stream. An acknowledged push's `confirmed` promise waits
1959
- until the stream delivers through the acknowledged index. A bootstrap
1960
- snapshot, or a stream that starts after a supplied initial index, delivers
1961
- through that index. Hydration does not otherwise confirm an unobserved
1962
- acknowledgement. A push never waits for bootstrap. An HTTP push does not
1963
- wait for a pending initial Promise either; a WebSocket push rides its
1964
- connection, which opens once that Promise settles.
1965
- Resumption preserves the stream's own position, even when
1966
- hydration has advanced state while paused.
1967
-
1968
- A `SessionUrl` function runs for each HTTP request or WebSocket connection.
1969
- Use it for session-specific paths. A2 still adds its `sessionId` and read-bound
1970
- query parameters. The function does not authorize that ID. Bind any path scope
1971
- or URL token to `operation.sessionId` in `authorize`; keep URL tokens short-lived
1972
- because URLs can appear in infrastructure logs. A multiplexed WebSocket URL is
1973
- a string because its connection carries more than one session.
1974
-
1975
- The framework-agnostic session client `experimental-a2/react` is built on: the SSE
1976
- subscription with frontier resume and reconnection, the optimistic push
1977
- queue with ack/rollback, and the local fold. `client.session(id, {
1978
- initial?, initialEvents?, participant? })` returns a
1979
- handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1980
- contract), `push()`, `loadHistory()`, `connect()`, `reconnect()`, and `close()`.
1981
- `connect()` takes a lease on the live stream and returns its release:
1982
- leases refcount per handle (the first connects, releasing the last
1983
- closes, releasing twice is a no-op), so independent consumers of one
1984
- identity-mapped handle never fight over the stream. `close()` is the
1985
- hard stop: it drops every outstanding lease and closes now; a later
1986
- `connect()` starts fresh.
1987
- Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1988
- (the same fields `useSession` exposes), and `push` returns the same
1989
- ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1990
- `setPresence()` and snapshots carry the `presence` map, exactly like
1991
- the hook; `participant` is the identity `setPresence` sends under:
1992
- stated once on `createClient` as the default for every handle, or per
1993
- session as the override. Use
1994
- it directly from any other framework, or none.
1995
-
1996
- Within one `A2Client`, repeated `session(id)` calls return the same live
1997
- handle. A later call with a further server-rendered frontier advances the
1998
- handle and does not drop pending optimistic events; a stale render cannot move
1999
- it backward. Optional `initialEvents` seed earlier raw events for history UIs
2000
- and merge into that feed. They do not affect the folded state or stream
2001
- frontier. Idle handles are evicted after `gcTime`. This memory layer is
2002
- separate from `experimental-a2/cache-indexeddb`: memory preserves identity
2003
- across route transitions, while IndexedDB preserves the replica across reloads.
2004
-
2005
- `initial` accepts the same `InitialSnapshot<S>` as React. A pending Promise
2006
- delays cold connection startup, including a connection acquired by `push`.
2007
- An HTTP push is sent without waiting; a WebSocket push waits for that
2008
- connection. It does not start another request. Replacing the source or calling `close()`
2009
- invalidates its pending result and any deferred adoption behind a write.
2010
- Releasing the last connection lease does not cancel the caller's Promise;
2011
- it can still seed cached data without reopening transport. Idle eviction
2012
- detaches A2's listener. Repeating the same Promise within that cached runtime
2013
- does not restart cancelled work.
2014
-
2015
- ## `experimental-a2/cache-indexeddb`
2016
-
2017
- ```ts
2018
- indexedDb(): A2ClientCache
2019
- ```
2020
-
2021
- A browser-side cache for `SessionProvider`: the session's events
2022
- (immutable; writes are idempotent by index), a folded snapshot
2023
- (invalidated by a reducer rename), and the pending-push queue.
2024
- `cache.clear(sessionId?)` is the logout story. Create instances in client
2025
- code; they're live objects, not serializable props. See
2026
- [Local-first](/guides/local-first).
2027
-
2028
- ## `experimental-a2/otel`
2029
-
2030
- ```ts
2031
- otel(options?: { tracer?: Tracer; tracerName?: string }): A2Telemetry
2032
- ```
2033
-
2034
- OpenTelemetry instrumentation for a server (`@opentelemetry/api` is a
2035
- peer dependency). Pass it as `telemetry`; spans nest through the active
2036
- context, so the happy path lands in one trace: request → append → drain →
2037
- each handler → the events those handlers append.
2038
-
2039
- ### The span catalogue
2040
-
2041
- All spans carry `a2.contract` and `a2.session_id`. Core spans describe the
2042
- durable runtime; `a2.ai.*` spans describe the built-in AI handlers:
2043
-
2044
- | Layer | Span | Wraps |
2045
- | ----- | ---- | ----- |
2046
- | Core | `a2.append` | validation + the store write (one batch) |
2047
- | Core | `a2.drain` | one drain pass, including concurrent handlers |
2048
- | Core | `a2.event` | one claimed dispatch, including its handler and completion |
2049
- | Core | `a2.claim` | one `claimAvailable` store operation |
2050
- | Core | `a2.commit` | one `completeAttempt` or `failAttempt` store operation |
2051
- | Core | `a2.state` | one `state()` read: snapshot-plus-tail load + reducer fold |
2052
- | Core | `a2.snapshot` | one background batch of checkpoint writes |
2053
- | AI | `a2.ai.control` | applying an input/work report/session-close decision and preparing its returned events |
2054
- | AI | `a2.ai.generate` | one generation handler, including Core calls and model/SDK work |
2055
- | AI | `a2.ai.context` | constructing model messages, including tool prompt reconstruction when uncached |
2056
- | AI | `a2.ai.compaction` | automatic summarization or the custom compaction callback |
2057
- | AI | `a2.ai.tool` | one durable tool handler, including application execution |
2058
- | AI | `a2.ai.ack` | waiting for a persisted work report's canonical events and control receipt |
2059
-
2060
- Durations measure elapsed time, including nested work: `a2.event` contains its AI
2061
- handler and later commit, AI handlers call Core, and `a2.state` includes reducer code.
2062
- Acknowledgements overlap control processing; claims can cover several events,
2063
- and snapshots run in the background. Summing spans or subtracting summed children
2064
- does not yield exclusive Core/AI overhead. Model and application I/O is included;
2065
- inspect its child spans for detail.
2066
-
2067
- Attributes arrive in two waves: **at start** (passed to `span()`) and
2068
- **mid-span** (via `handle.setAttribute`; outcomes and counts aren't
2069
- known until the work ran). A recorder must merge both; the ones you
2070
- alert on are all mid-span.
2071
-
2072
- | Attribute | Span | When | Values |
2073
- | --------------------- | ----------- | ----- | ----------------------------------------------------- |
2074
- | `a2.append.source` | `a2.append` | start | `external` \| `handler` |
2075
- | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
2076
- | `a2.append.types` | `a2.append` | start | comma-joined event types |
2077
- | `a2.append.count` | `a2.append` | start | batch size |
2078
- | `a2.append.armed` | `a2.append` | mid | `false` when the scheduler arm failed and this append degraded to append-driven healing |
2079
- | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
2080
- | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
2081
- | `a2.claim.outcome` | `a2.claim` | mid | `claimed` \| `busy` \| `settled` |
2082
- | `a2.claim.count` | `a2.claim` | mid | number of claimed events, or zero |
2083
- | `a2.commit.operation` | `a2.commit` | start | `complete` \| `fail` |
2084
- | `a2.commit.index` | `a2.commit` | start | event log position |
2085
- | `a2.commit.attempt` | `a2.commit` | start | durable 1-based attempt ordinal |
2086
- | `a2.commit.outcome` | `a2.commit` | mid | `completed` \| `failed` \| `dead_lettered` \| `superseded` |
2087
- | `a2.event.type` | `a2.event` | start | the event's type |
2088
- | `a2.event.index` | `a2.event` | start | log position |
2089
- | `a2.event.id` | `a2.event` | start | event id |
2090
- | `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
2091
- | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
2092
- | `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
2093
- | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` \| `surrendered` |
2094
- | `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
2095
- | `a2.state.reducer` | `a2.state` | start | the reducer's name |
2096
- | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) \| `memory` (served from the server's in-memory state) |
2097
- | `a2.state.folded` | `a2.state` | mid | events folded past the snapshot |
2098
- | `a2.state.index` | `a2.state` | mid | the frontier the returned state reflects |
2099
- | `a2.snapshot.reducer` | `a2.snapshot` | start | the reducer's name |
2100
- | `a2.snapshot.index` | `a2.snapshot` | start | greatest checkpoint index in the write batch |
2101
- | `a2.snapshot.count` | `a2.snapshot` | start | number of checkpoints in the write batch |
2102
- | `a2.ai.control.type` | `a2.ai.control` | start | `ai.control.requested` \| `ai.work.reported` \| `ai.session.closed` |
2103
- | `a2.ai.control.id` | `a2.ai.control` | start | triggering event id |
2104
- | `a2.ai.control.attempt` | `a2.ai.control` | start | durable 1-based attempt ordinal |
2105
- | `a2.ai.generate.request_id` | `a2.ai.generate` | start | generation request event id |
2106
- | `a2.ai.generate.attempt` | `a2.ai.generate` | start | durable 1-based attempt ordinal |
2107
- | `a2.ai.context.request_id` | `a2.ai.context` | start | generation request event id |
2108
- | `a2.ai.context.phase` | `a2.ai.context` | start | `generation` \| `refresh` \| `tool` |
2109
- | `a2.ai.compaction.request_id` | `a2.ai.compaction` | start | generation request event id |
2110
- | `a2.ai.compaction.generation_id` | `a2.ai.compaction` | start | generation attempt id |
2111
- | `a2.ai.compaction.policy` | `a2.ai.compaction` | start | `automatic` \| `custom` |
2112
- | `a2.ai.tool.request_id` | `a2.ai.tool` | start | originating generation request event id |
2113
- | `a2.ai.tool.call_id` | `a2.ai.tool` | start | tool call id |
2114
- | `a2.ai.tool.name` | `a2.ai.tool` | start | tool name |
2115
- | `a2.ai.tool.attempt` | `a2.ai.tool` | start | durable 1-based attempt ordinal |
2116
- | `a2.ai.ack.request_id` | `a2.ai.ack` | start | reporting worker event id |
2117
- | `a2.ai.ack.report_id` | `a2.ai.ack` | start | work report event id, matching the control span's id |
2118
-
2119
- Drain outcomes: `settled` means nothing actionable is left; `busy` means live
2120
- per-event claims remain; `stalled` means a caught failure needs a later retry.
2121
- A dead-lettered event blocks only later events in its lane. Other lanes remain
2122
- eligible.
2123
-
2124
- A failing handler marks its `a2.event` span with the exception and
2125
- error status; `a2.event.outcome = dead_lettered` is the attribute to
2126
- alert on. `superseded` means a stale completion or failure did not change the
2127
- log.
2128
- `A2Telemetry` is a one-method interface; anything with
2129
- `span(name, attributes, fn)` works;
2130
- `otel()` is simply the adapter A2 ships. This catalogue is a contract:
2131
- renames and additions are breaking for dashboards, and are called out in the
2132
- package's `CHANGELOG.md`.
2133
-
2134
- ## `experimental-a2/scheduler-vercel`
2135
-
2136
- `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
2137
-
2138
- ```ts server/scheduler-vercel.ts
2139
- import { vercelQueues } from 'experimental-a2/scheduler-vercel'
2140
-
2141
- export const scheduler = vercelQueues()
2142
- ```
2143
-
2144
- | Option | Type | Default |
2145
- | --- | --- | --- |
2146
- | `topic` | `string` matching `[A-Za-z0-9_-]+` | `'a2'` |
2147
- | `deploymentId` | `string \| null` | `VERCEL_DEPLOYMENT_ID` |
2148
- | `transport` | `QueueTransport` | lazy `@vercel/queue` client |
2149
- | `clock` | `{ now(): Date }` | system clock |
2150
-
2151
- `deploymentId` is passed to the Queue SDK unchanged and applies to every task,
2152
- including recovery watchdogs. Omit it to use `VERCEL_DEPLOYMENT_ID`, pass a
2153
- deployment ID to target that deployment, or pass `null` for deploymentless
2154
- delivery. Publishing and consuming clients must use the same deployment scope.
2155
- The same configured client handles sends, payload fetches, acknowledgements,
2156
- and visibility changes. Callback contents never choose the client.
2157
-
2158
- Pinned tasks stay with their target deployment after another deployment is
2159
- promoted. Deploymentless delivery lets Vercel choose an eligible consumer; it
2160
- does not select the latest production deployment. Every eligible consumer must
2161
- use `deploymentId: null` and remain compatible with pending tasks and session
2162
- work.
2163
-
2164
- The adapter retains every task for seven days and accepts delays through six
2165
- days, leaving the final day for delivery and retries. `transport` and `clock`
2166
- are advanced injection seams for tests and custom delivery. An injected
2167
- transport receives the topic, an exact `SchedulerTask`, and send options.
2168
- `deploymentId` configures the built-in Queue SDK client only; an injected
2169
- transport owns its provider configuration.
2170
-
2171
- ## `experimental-a2/scheduler-qstash`
2172
-
2173
- `qstash(options?)` returns an `A2Scheduler` backed by QStash HTTP delivery.
2174
- Vercel Queues remains the recommended default on Vercel. Use QStash when you
2175
- want a scheduler that calls a stable public endpoint.
2176
-
2177
- ```ts server/scheduler-qstash.ts
2178
- import { qstash } from 'experimental-a2/scheduler-qstash'
2179
-
2180
- export const scheduler = qstash()
2181
- ```
2182
-
2183
- | Option | Type | Default |
2184
- | --- | --- | --- |
2185
- | `url` | `string` | conventional local or Vercel callback; required for process-less runtimes, other production, and custom routes |
2186
- | `token` | `string` | `QSTASH_TOKEN` |
2187
- | `currentSigningKey` | `string` | `QSTASH_CURRENT_SIGNING_KEY` |
2188
- | `nextSigningKey` | `string` | `QSTASH_NEXT_SIGNING_KEY` |
2189
- | `headers` | `Readonly<Record<string, string>>` | no additional callback headers |
2190
- | `client` | `QStashPublishingClient` | lazy `@upstash/qstash` client |
2191
- | `retries` | non-negative integer | the QStash provider default |
2192
- | `maxBodyBytes` | positive integer | `1_048_576` (1 MiB) for callbacks and reserved outbound bodies |
2193
- | `transport` | `QStashTransport` | lazy `@upstash/qstash` client and receiver |
2194
- | `clock` | `{ now(): Date }` | system clock |
2195
-
2196
- `headers` adds static headers to every callback delivery, including recovery
2197
- successors. A2 validates and snapshots the record when `qstash()` runs, then
2198
- asks QStash to redact every configured value from its dashboard and API. QStash
2199
- still receives, stores, and delivers the original values. Header names are
2200
- case-insensitive. Provider-owned `Upstash-*` headers, `Content-Type*`, HTTP
2201
- framing headers, and A2's managed Vercel identity headers are reserved.
2202
-
2203
- `client` accepts the structural `publishJSON(...)` part of a configured
2204
- `@upstash/qstash` `Client`. A2 still creates the `Receiver` lazily from the
2205
- signing-key options. `client` cannot be combined with `token` or `transport`.
2206
- Use it to configure the QStash API endpoint, publish-request retry policy, or
2207
- telemetry. Use A2's `headers` option for callback credentials. Client-global
2208
- callback headers and redaction are outside A2's merge contract. The injected
2209
- client receives raw callback credentials, including an automatic preview
2210
- identity, and must honor the per-publish redaction request.
2211
-
2212
- `transport` and `clock` are advanced injection seams for tests and custom
2213
- delivery. A `QStashTransport` implements `publish(request)` and
2214
- `verify(request)`. Publish requests contain the callback `url`, a complete
2215
- `SchedulerTask` body, optional callback `headers`, optional `redact.header`
2216
- names, optional Unix-seconds `notBefore`, and a deterministic
2217
- `deduplicationId`. Store-outage successors add provider-private generation
2218
- metadata to that body. Custom transports must preserve the body, deliver every
2219
- header, and apply every redaction request. An explicit `retries` value is
2220
- forwarded; omission lets QStash use its provider default. Verify requests
2221
- contain the signature, raw body, exact callback URL, and optional Upstash
2222
- region.
2223
-
2224
- An explicit `url` always wins. Otherwise, Vercel production resolves to
2225
- `https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler` and preview resolves
2226
- to `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`. Outside those deployments,
2227
- a runtime that exposes `process.env` and is not in `NODE_ENV=production`
2228
- resolves to `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and passes
2229
- `devMode: true` to the SDK client and receiver. `QSTASH_DEV` is not required.
2230
- `PORT` must be an integer from 1 through 65535. `QSTASH_DEV_PORT` configures the
2231
- local QStash server, not the application callback. Automatic startup requires
2232
- `@upstash/qstash` 2.11.0 or newer in a Node.js runtime. The lazy transport
2233
- starts it on the first publish or verification. The direct SDK override means
2234
- `QSTASH_DEV=false` does not disable an inferred local route.
2235
-
2236
- A non-Vercel production runtime or a process-less runtime requires an explicit
2237
- absolute URL. Pass one for a custom host or path, an application port not
2238
- available through `PORT`, or Docker networking. An Edge runtime that exposes
2239
- `process.env` can infer the local URL, but it must start the QStash development
2240
- server separately. Outside production, an explicit URL does not override the
2241
- SDK's development mode, so `QSTASH_DEV` and manually supplied `QSTASH_URL` plus
2242
- credentials retain their SDK-defined behavior. A2 passes `devMode: false` in
2243
- production. The inferred Vercel hosts are moving aliases, so pending tasks can
2244
- reach later deployments on the same production or branch URL.
2245
-
2246
- Mount the application handler once and pass every server that shares the
2247
- scheduler:
2248
-
2249
- ```ts app/api/a2/scheduler/route.ts
2250
- import { billingServer, ordersServer, scheduler } from '@/server'
2251
-
2252
- export const POST = scheduler.handler(ordersServer, billingServer)
2253
- ```
2254
-
2255
- The route must be reachable by the selected QStash server after any platform
2256
- edge authentication. The development server calls localhost directly. The
2257
- adapter reads its raw body once and verifies `Upstash-Signature`, the exact URL,
2258
- and `Upstash-Region` before it parses the delivery or touches a server. Missing
2259
- or invalid signatures return 401, so ordinary unsigned traffic cannot execute
2260
- scheduler work. A signed malformed envelope returns 489 with
2261
- `Upstash-NonRetryable-Error: true`. The same non-retryable response applies
2262
- when the declared or streamed body exceeds `maxBodyBytes`. Before an initial
2263
- publish, the adapter checks the normalized task with enough reserved room for
2264
- its largest possible private store-outage lineage. Append and clear tasks
2265
- reserve a maximum safe-integer generation. Drain tasks also reserve their actual root
2266
- `dueAt`. Store-outage successors check their actual body. The adapter therefore
2267
- cannot initially accept a task that would exceed the same limit solely when
2268
- lineage is added. Raise the option to match a larger paid-plan payload limit
2269
- when needed. Keep one value across every deployment that can receive this
2270
- callback URL. A lower value deployed while older tasks remain can return a
2271
- non-retryable 489 for a task accepted by the previous value.
2272
-
2273
- For an inferred Vercel preview callback, the adapter reads the current
2274
- invocation's `x-vercel-oidc-token` for each publish. When it is a parseable JWT
2275
- whose expiration remains at least 60 seconds after the task's first due time,
2276
- the adapter supplies it as `x-vercel-trusted-oidc-idp-token` and requests QStash
2277
- header redaction. A matching Vercel Trusted Sources rule can then admit the
2278
- first delivery. QStash receives the credential even though its dashboard and
2279
- API redact it.
2280
-
2281
- This is opportunistic, not durable. Missing, malformed, and near-expiry tokens
2282
- fall back to an ordinary publish, preserving public-preview behavior. An
2283
- explicit `url` opts out. Inferred production and local callbacks never forward
2284
- the token. QStash stores the static header and reuses it on retries, so delayed
2285
- tasks and retries can outlive it. Use an explicit automation bypass or public
2286
- ingress when every protected delivery must remain authorized. Applications can
2287
- supply a dedicated bypass through `headers`; A2 requests redaction for its
2288
- value, but pending messages retain the captured credential after rotation. A
2289
- delivery-time Vercel relay remains a separate application architecture that
2290
- requires a Trusted Sources rule, strict target allowlisting, and a versioned
2291
- routing envelope. QStash signature verification remains mandatory after the
2292
- edge-access check.
2293
-
2294
- Settled deliveries return 200. Busy deliveries await a durable successor
2295
- watchdog before returning 200. Stalled deliveries publish a fresh successor
2296
- before returning 200, which resets the per-message retry budget. A retryable
2297
- store outage also hands drain, append, and clear tasks to a fresh successor
2298
- before acknowledging. Store-outage successors wait 30 seconds, then multiply the delay
2299
- by four for each signed generation, capped at one day. Their generation keeps
2300
- increasing after the cap so each handoff has a new deduplication id. Drain
2301
- lineage includes the first failing task's `dueAt`, contract, and session.
2302
- Append and clear lineage includes the task id, contract, and session. Duplicate delivery of one
2303
- generation therefore publishes the same successor key without merging a later
2304
- drain episode. Append tasks otherwise return 200 after their ordinary fixed-id
2305
- append succeeds and any recovery watchdog for pending handler work is durable.
2306
- Clear tasks return 200 after their conditional clear and its deletion finish.
2307
- Unknown contracts, missing scheduler configuration, schema failures, and
2308
- successor publish failures keep the current delivery retryable. Delivery is at
2309
- least once, so handler side effects still need idempotency keys.
2310
-
2311
- The adapter leaves `retries` unset by default so QStash applies its provider
2312
- default. An explicit non-negative value is passed through. Every delivery
2313
- attempt is billable. A prolonged outage in which QStash cannot reach the
2314
- callback still requires DLQ monitoring and redrive because no running callback
2315
- can publish a successor. QStash's
2316
- documented 10-minute deduplication window can reduce duplicate watchdogs but is
2317
- not part of A2's correctness model. Stable scheduled-event ids also make timer
2318
- redelivery safe after that window. One-shot delays are currently capped at 7
2319
- days on free plans and 1 year on usage-based plans, with no fixed maximum on
2320
- fixed plans. Leave at least one second of margin at a plan maximum because
2321
- `notBefore` uses whole Unix seconds. The adapter carries both recovery watchdogs and
2322
- `session.schedule` tasks.
2323
-
2324
- The built-in QStash and Vercel adapters preserve the original provider error
2325
- from a rejected send. A recognized definite 4xx status or error is terminal
2326
- except for 408, 409, 425, and 429. Those statuses, 5xx responses, network
2327
- failures, and errors with no recognized status remain retryable. The Vercel
2328
- adapter also recognizes the SDK's name-only `BadRequestError`,
2329
- `UnauthorizedError`, and `ForbiddenError` as terminal. Its native
2330
- duplicate-message error is success. A2's durable AI tool execution uses this
2331
- private classification. Direct `session.schedule()` calls still reject with
2332
- the original error object.
2333
-
2334
- ## Entry points
2335
-
2336
- | Entry point | Ships | Peer dependency |
2337
- | -------------------- | ---------------------------------- | --------------- |
2338
- | `experimental-a2` | `contract`, `A2Error`: isomorphic core | none |
2339
- | `experimental-a2/server` | `createServer`: implement a contract | none |
2340
- | `experimental-a2/client` | `createClient`: framework-agnostic session client | none |
2341
- | `experimental-a2/react` | `createReact` | `react` |
2342
- | `experimental-a2/ai` | `agentContract`, `handlerContext`, `inputs`, and the AI reducer: isomorphic | `ai` |
2343
- | `experimental-a2/ai/server` | `agent`: the AI SDK runner as an A2 server | `ai` |
2344
- | `experimental-a2/ai/client` | `createAgentClient`: contract-free agent client | `ai` |
2345
- | `experimental-a2/ai/react` | `useAgent` | `ai`, `react` |
2346
- | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
2347
- | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
2348
- | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
2349
- | `experimental-a2/store-sqlite` | SQLite store backend | none |
2350
- | `experimental-a2/store-memory` | in-memory store backend | none |
2351
- | `experimental-a2/scheduler-vercel` | `vercelQueues`: recovery and timers | `@vercel/queue` |
2352
- | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
2353
- | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
2354
- | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
2355
-
2356
- Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
2357
- test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
2358
- pulls in Postgres, never `experimental-a2` itself, and never your client bundle.