experimental-a2 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/dist/ai-CFNeCrRl.d.ts +334 -0
  3. package/dist/ai-CFNeCrRl.d.ts.map +1 -0
  4. package/dist/ai-server.browser.js +2 -0
  5. package/dist/ai-server.browser.js.map +1 -0
  6. package/dist/ai-server.d.ts +49 -9
  7. package/dist/ai-server.d.ts.map +1 -0
  8. package/dist/ai-server.js +123 -49
  9. package/dist/ai-server.js.map +1 -0
  10. package/dist/ai.d.ts +2 -303
  11. package/dist/ai.js +233 -86
  12. package/dist/ai.js.map +1 -0
  13. package/dist/cli-B3VuxoDe.js +599 -0
  14. package/dist/cli-B3VuxoDe.js.map +1 -0
  15. package/dist/cli-bin.d.ts +1 -0
  16. package/dist/cli-bin.js +7 -0
  17. package/dist/cli-bin.js.map +1 -0
  18. package/dist/cli.d.ts +20 -0
  19. package/dist/cli.d.ts.map +1 -0
  20. package/dist/cli.js +2 -0
  21. package/dist/client-BKlyLiOU.js +1008 -0
  22. package/dist/client-BKlyLiOU.js.map +1 -0
  23. package/dist/client-D7mvIXrF.d.ts +191 -0
  24. package/dist/client-D7mvIXrF.d.ts.map +1 -0
  25. package/dist/client.d.ts +2 -90
  26. package/dist/client.js +1 -409
  27. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
  28. package/dist/contract-48bUMgcL.js.map +1 -0
  29. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
  30. package/dist/contract-jIfaR085.d.ts.map +1 -0
  31. package/dist/devtools-J_jZ2vQf.d.ts +152 -0
  32. package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
  33. package/dist/devtools-kJJaORn-.js +340 -0
  34. package/dist/devtools-kJJaORn-.js.map +1 -0
  35. package/dist/devtools-server.browser.js +3 -1
  36. package/dist/devtools-server.browser.js.map +1 -0
  37. package/dist/devtools-server.d.ts +4 -3
  38. package/dist/devtools-server.d.ts.map +1 -0
  39. package/dist/devtools-server.js +226 -43
  40. package/dist/devtools-server.js.map +1 -0
  41. package/dist/devtools.d.ts +2 -0
  42. package/dist/devtools.js +2 -0
  43. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
  44. package/dist/errors-BQuJpe82.js.map +1 -0
  45. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
  46. package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
  47. package/dist/http.d.ts +135 -28
  48. package/dist/http.d.ts.map +1 -0
  49. package/dist/http.js +647 -61
  50. package/dist/http.js.map +1 -0
  51. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
  52. package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
  53. package/dist/index.d.ts +5 -5
  54. package/dist/index.js +2 -2
  55. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
  56. package/dist/inspection-DaxB5jM2.js.map +1 -0
  57. package/dist/internal-DstsI6Re.js +211 -0
  58. package/dist/internal-DstsI6Re.js.map +1 -0
  59. package/dist/otel.d.ts +3 -2
  60. package/dist/otel.d.ts.map +1 -0
  61. package/dist/otel.js +2 -0
  62. package/dist/otel.js.map +1 -0
  63. package/dist/platform-B4TnJtWu.js +34 -0
  64. package/dist/platform-B4TnJtWu.js.map +1 -0
  65. package/dist/react.d.ts +47 -15
  66. package/dist/react.d.ts.map +1 -0
  67. package/dist/react.js +30 -15
  68. package/dist/react.js.map +1 -0
  69. package/dist/retryable-lazy-DZWmHpii.js +2 -0
  70. package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
  71. package/dist/scheduler-qstash.d.ts +79 -0
  72. package/dist/scheduler-qstash.d.ts.map +1 -0
  73. package/dist/scheduler-qstash.js +501 -0
  74. package/dist/scheduler-qstash.js.map +1 -0
  75. package/dist/scheduler-task-BpzhPnRS.js +56 -0
  76. package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
  77. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
  78. package/dist/scheduler-vercel.d.ts.map +1 -0
  79. package/dist/scheduler-vercel.js +228 -0
  80. package/dist/scheduler-vercel.js.map +1 -0
  81. package/dist/server-C72KOw51.js +1457 -0
  82. package/dist/server-C72KOw51.js.map +1 -0
  83. package/dist/server-DUF9pjsx.d.ts +261 -0
  84. package/dist/server-DUF9pjsx.d.ts.map +1 -0
  85. package/dist/server.browser.js +2 -0
  86. package/dist/server.browser.js.map +1 -0
  87. package/dist/server.d.ts +4 -155
  88. package/dist/server.js +2 -2
  89. package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
  90. package/dist/store-DysUkTH3.d.ts.map +1 -0
  91. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
  92. package/dist/store-N8PXxDAS.js.map +1 -0
  93. package/dist/store-codec-DTG0Ftek.js +10 -0
  94. package/dist/store-codec-DTG0Ftek.js.map +1 -0
  95. package/dist/store-memory.d.ts +12 -0
  96. package/dist/store-memory.d.ts.map +1 -0
  97. package/dist/{log-memory.js → store-memory.js} +145 -34
  98. package/dist/store-memory.js.map +1 -0
  99. package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
  100. package/dist/store-polling-dSeLxzfb.js.map +1 -0
  101. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
  102. package/dist/store-postgres.d.ts.map +1 -0
  103. package/dist/{log-postgres.js → store-postgres.js} +214 -24
  104. package/dist/store-postgres.js.map +1 -0
  105. package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
  106. package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
  107. package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
  108. package/dist/store-redis-http.d.ts.map +1 -0
  109. package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
  110. package/dist/store-redis-http.js.map +1 -0
  111. package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
  112. package/dist/store-redis.d.ts.map +1 -0
  113. package/dist/{log-redis.js → store-redis.js} +101 -39
  114. package/dist/store-redis.js.map +1 -0
  115. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
  116. package/dist/store-sqlite.d.ts.map +1 -0
  117. package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
  118. package/dist/store-sqlite.js.map +1 -0
  119. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
  120. package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
  121. package/dist/testing.browser.d.ts +1 -0
  122. package/dist/testing.browser.js +6 -0
  123. package/dist/testing.browser.js.map +1 -0
  124. package/dist/testing.d.ts +32 -0
  125. package/dist/testing.d.ts.map +1 -0
  126. package/dist/testing.js +103 -0
  127. package/dist/testing.js.map +1 -0
  128. package/dist/validate-XKT4FSNn.js +2 -0
  129. package/dist/validate-XKT4FSNn.js.map +1 -0
  130. package/dist/wire-BFQmSJ-9.js +284 -0
  131. package/dist/wire-BFQmSJ-9.js.map +1 -0
  132. package/docs/01-quickstart.mdx +3 -3
  133. package/docs/concepts/01-contracts.mdx +18 -13
  134. package/docs/concepts/02-handlers.mdx +7 -7
  135. package/docs/concepts/03-durability.mdx +21 -21
  136. package/docs/concepts/04-state.mdx +18 -21
  137. package/docs/guides/01-timers.mdx +154 -49
  138. package/docs/guides/02-cancellation.mdx +30 -4
  139. package/docs/guides/03-react.mdx +76 -57
  140. package/docs/guides/04-local-first.mdx +1 -1
  141. package/docs/guides/05-production.mdx +310 -61
  142. package/docs/guides/06-ai-agents.mdx +252 -74
  143. package/docs/guides/07-devtools.mdx +137 -12
  144. package/docs/guides/08-application-data.mdx +1 -1
  145. package/docs/guides/09-presence.mdx +263 -0
  146. package/docs/guides/10-transports.mdx +140 -0
  147. package/docs/index.mdx +18 -14
  148. package/docs/reference/01-api.mdx +839 -110
  149. package/docs/reference/02-errors.mdx +13 -6
  150. package/package.json +24 -7
  151. package/src/ai-coordinator.ts +358 -0
  152. package/src/ai-projector.ts +524 -0
  153. package/src/ai-sdk-step.ts +261 -0
  154. package/src/ai-server.browser.ts +5 -0
  155. package/src/ai-server.ts +1719 -0
  156. package/src/ai.ts +2155 -0
  157. package/src/cache-indexeddb.ts +10 -0
  158. package/src/cli-bin.ts +5 -0
  159. package/src/cli.ts +1046 -0
  160. package/src/client.ts +1826 -0
  161. package/src/contract.ts +206 -0
  162. package/src/deterministic-id.ts +72 -0
  163. package/src/devtools-app.ts +989 -0
  164. package/src/devtools-server.browser.ts +5 -0
  165. package/src/devtools-server.ts +604 -0
  166. package/src/devtools.ts +716 -0
  167. package/src/errors.ts +50 -0
  168. package/src/http.ts +394 -0
  169. package/src/idempotent-replay.ts +53 -0
  170. package/src/index.ts +37 -0
  171. package/src/inspection.ts +39 -0
  172. package/src/internal.ts +426 -0
  173. package/src/otel.ts +59 -0
  174. package/src/platform.ts +60 -0
  175. package/src/push-envelope.ts +137 -0
  176. package/src/react.ts +284 -0
  177. package/src/reducer.ts +108 -0
  178. package/src/retryable-lazy.ts +27 -0
  179. package/src/scheduler-qstash.ts +915 -0
  180. package/src/scheduler-task.ts +106 -0
  181. package/src/scheduler-vercel.ts +437 -0
  182. package/src/server.browser.ts +12 -0
  183. package/src/server.ts +2700 -0
  184. package/src/session-socket.ts +548 -0
  185. package/src/sse.ts +141 -0
  186. package/src/standard-schema.ts +77 -0
  187. package/src/store-codec.ts +10 -0
  188. package/src/store-memory.ts +788 -0
  189. package/src/store-polling.ts +102 -0
  190. package/src/store-postgres.ts +1212 -0
  191. package/src/store-redis-core.ts +1494 -0
  192. package/src/store-redis-http.ts +116 -0
  193. package/src/store-redis.ts +458 -0
  194. package/src/store-sqlite.ts +1108 -0
  195. package/src/store.ts +385 -0
  196. package/src/telemetry.ts +47 -0
  197. package/src/testing.browser.ts +5 -0
  198. package/src/testing.ts +185 -0
  199. package/src/validate.ts +39 -0
  200. package/src/wire.ts +454 -0
  201. package/dist/internal-gCd5qMry.js +0 -44
  202. package/dist/log-memory.d.ts +0 -11
  203. package/dist/recovery-vercel.js +0 -119
  204. package/dist/server-BcLa4RFL.js +0 -877
  205. package/dist/wire-BVsgR8o9.js +0 -62
@@ -11,6 +11,7 @@ description: "The whole surface, small enough to read in one sitting: contract,
11
11
  a2.contract(options: {
12
12
  name: string
13
13
  events: Record<string, StandardSchemaV1>
14
+ presence?: Record<string, StandardSchemaV1>
14
15
  }): Contract
15
16
  ```
16
17
 
@@ -20,6 +21,11 @@ messages. The result is a plain, importable, **isomorphic** value: the server
20
21
  implements it, reducers derive from it, the browser types its pushes off it.
21
22
  See [Contracts and sessions](/concepts/contracts).
22
23
 
24
+ `presence` is the optional second vocabulary: each key a field of one
25
+ participant's ephemeral state, each value its schema. Presence fields are
26
+ replicated last-writer-wins and never stored in the log; reducers cannot
27
+ see them. See [Presence](/guides/presence).
28
+
23
29
  Schemas are [Standard Schema](https://standardschema.dev): Zod, Valibot,
24
30
  ArkType, anything that implements it. Validators must be synchronous
25
31
  (async ones are rejected here, at definition time), and the validated
@@ -63,10 +69,11 @@ Everything A2 throws. One class, discriminated by `code`. See
63
69
  ```ts
64
70
  createServer(options: {
65
71
  contract: Contract // the vocabulary this server implements
66
- log?: A2Log // default: sqlite in dev, memory in tests, required in prod
67
- recovery?: A2Recovery
72
+ store?: A2Store // default: sqlite in dev, memory in tests, required in prod
73
+ scheduler?: A2Scheduler
68
74
  telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
69
75
  validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
76
+ presence?: { ttlMs?: number } // presence expiry policy; default 60s
70
77
  handlers?: {
71
78
  [type]:
72
79
  | Handler
@@ -99,28 +106,42 @@ For example, `lane: ({ event }) => event.payload.warehouseId` serializes work
99
106
  per warehouse while different warehouses continue concurrently.
100
107
 
101
108
  Handlers can return one event or an array. A2 marks the triggering event
102
- processed and appends the returned batch in one atomic log operation. Returned
109
+ processed and appends the returned batch in one atomic store operation. Returned
103
110
  events do not exist when the handler throws. `ctx.session.append(name, ...events)`
104
111
  is different: it commits immediately, so its events may run while the current
105
112
  handler is still active unless a lane orders them.
113
+ `ctx.session.schedule(name, timing, ...events)` waits for provider acceptance;
114
+ its events append later and are not part of the parent completion transaction.
106
115
 
107
116
  Handlers are optional per event type. An event type without one settles in
108
117
  the append transaction with no dispatch attempt. If the session has no older
109
- pending handler work, A2 starts no drain or recovery arm. A server with no
118
+ pending handler work, A2 starts no drain or scheduler arm. A server with no
110
119
  handlers is a durable event log with no reaction infrastructure. See
111
120
  [Events without handlers](/concepts/handlers#events-without-handlers).
112
121
 
113
122
  Server-only by construction: `experimental-a2/server` is the only entry point that
114
- can reach a log backend, and its exports map resolves to a loud error
123
+ can reach a store backend, and its exports map resolves to a loud error
115
124
  under the browser condition.
116
125
 
117
- `validatePush({ sessionId, events })` runs only when `events` came from
118
- `parsePushBody()`. It runs before contract schema validation and before the log
119
- append, so throwing rejects the complete push without writing anything. Direct
120
- trusted server appends and handler appends bypass it. `parsePushBody()` creates
126
+ `validatePush(context)` runs only for input that arrived over the wire
127
+ through `handle`'s push lane, once per plane. The events plane invokes it with `{ sessionId, events }` before
128
+ contract schema validation and before the store append, so throwing rejects the
129
+ complete push without writing anything. The presence plane invokes it with
130
+ `{ sessionId, events: [], presence }`, the whole pushed patch with its
131
+ participant, before field validation and the broadcast, so authorizing the
132
+ participant id (and applying any size or cardinality policy) happens at the
133
+ same seam. The patch arrives frozen: authorize, don't rewrite (a mutation
134
+ attempt throws and fails the push). Direct trusted server appends, handler appends, and server-side
135
+ `setPresence` bypass it. The envelope parser inside `handle` creates
121
136
  the runtime provenance brand after reading the envelope; a caller-supplied
122
137
  field with the same name is ignored, and the brand is not stored in the log.
123
138
 
139
+ `presence.ttlMs` sets how long a presence value survives without a
140
+ refreshing set (default 60 seconds, as a positive integer of
141
+ milliseconds). Expiry counts on the storage's own clock, never on the
142
+ sender's stamp. The option is valid only on contracts that declare
143
+ `presence`; anywhere else construction throws a `TypeError`.
144
+
124
145
  `abortOn` names the events that fire `ctx.signal` while a handler runs.
125
146
  an array matches by type; an object takes per-type predicates for
126
147
  targeted cancellation:
@@ -144,8 +165,8 @@ The context every handler receives:
144
165
  | ------------- | --------------------------------------------------------- |
145
166
  | `ctx.event` | `Event`: the triggering event |
146
167
  | `ctx.attempt` | durable 1-based dispatch claim ordinal |
147
- | `ctx.session` | this session's `id`, `append`, `history`, `state`, `stream` |
148
- | `ctx.signal` | `AbortSignal`: active only with `abortOn` |
168
+ | `ctx.session` | this session's `id`, `append`, `schedule`, `history`, `state`, `stream`, `setPresence` |
169
+ | `ctx.signal` | `AbortSignal`: fires on lease lapse (`CLAIM_EXPIRED`), proven supersession (`SUPERSEDED_ATTEMPT`), and matching `abortOn` events |
149
170
 
150
171
  `ctx.attempt` starts at `1` and increments on every durable claim. It may skip
151
172
  when a process dies before handler entry.
@@ -172,6 +193,11 @@ event `id` wins over the generated id, which lets different triggering events
172
193
  converge on one fact. A root `server.session(id).append(...events)` takes no
173
194
  name.
174
195
 
196
+ `ctx.session.schedule(name, timing, ...events)` includes the triggering event
197
+ in its stable task identity. A relative delay is anchored to that event's
198
+ durable `createdAt`, so a handler retry keeps the same delivery time. The call
199
+ returns `Promise<void>` after the configured scheduler accepts the task.
200
+
175
201
  A state read and following append are not atomic. Concurrent appends and
176
202
  retries may move the frontier between them. A generic join should use a
177
203
  monotone readiness predicate and a stable explicit output event `id`, so every
@@ -193,14 +219,18 @@ const session = server.session('order-42')
193
219
 
194
220
  session.id
195
221
  session.append(...events)
196
- session.history()
222
+ session.schedule('expire-order', { delay: '5d' }, {
223
+ type: 'expired',
224
+ payload: {},
225
+ })
226
+ session.history({ gte, lte })
197
227
  session.state(reducer)
198
- session.stream({ startAt })
228
+ session.stream({ startAfter })
199
229
  ```
200
230
 
201
231
  `session.id` is the id passed to `server.session(id)`. Root `append` takes only
202
232
  events. The handler-local form at `ctx.session.append` adds its required name
203
- before the events.
233
+ before the events. Both root and handler sessions expose `schedule`.
204
234
 
205
235
  ### `server.drain(sessionId)`
206
236
 
@@ -211,15 +241,15 @@ server.drain(sessionId: string): Promise<{ settled: boolean }>
211
241
  Claims every currently eligible event and runs their handlers concurrently.
212
242
  `settled` means no actionable or live-claimed work remains. A dead-lettered
213
243
  event can leave later work in its lane blocked while other lanes continue.
214
- You'll rarely call this yourself; it is the primitive recovery callbacks use.
244
+ You'll rarely call this yourself; it is the primitive scheduler callbacks use.
215
245
  The public result stays this simple boolean.
216
246
 
217
- ### `A2Log`
247
+ ### `A2Store`
218
248
 
219
249
  Custom adapters implement these atomic drain methods:
220
250
 
221
251
  ```ts
222
- // Custom server log adapter:
252
+ // Custom server store adapter:
223
253
  type EventCause = {
224
254
  index: number
225
255
  attempt: number
@@ -255,12 +285,12 @@ type StoredEvent = Event & {
255
285
  failedAt: Date | null
256
286
  }
257
287
 
258
- type LogAppendResult = {
288
+ type StoreAppendResult = {
259
289
  events: StoredEvent[]
260
290
  hasPending: boolean
261
291
  }
262
292
 
263
- type LogClaimAvailableResult =
293
+ type StoreClaimAvailableResult =
264
294
  | { outcome: 'claimed'; events: StoredEvent[] }
265
295
  | { outcome: 'busy'; retryAt: Date }
266
296
  | { outcome: 'settled' }
@@ -269,11 +299,11 @@ type CompleteAttemptResult =
269
299
  | { outcome: 'completed'; events: StoredEvent[] }
270
300
  | { outcome: 'superseded' }
271
301
 
272
- interface A2Log {
302
+ interface A2Store {
273
303
  append(
274
304
  sessionId: string,
275
305
  events: AppendEvent[],
276
- ): Promise<LogAppendResult>
306
+ ): Promise<StoreAppendResult>
277
307
 
278
308
  claimAvailable(options: {
279
309
  sessionId: string
@@ -281,15 +311,15 @@ interface A2Log {
281
311
  ttlMs: number
282
312
  expiresAtMs?: number
283
313
  excludeIndexes?: readonly number[]
284
- }): Promise<LogClaimAvailableResult>
314
+ }): Promise<StoreClaimAvailableResult>
285
315
 
286
316
  renewClaims(options: {
287
317
  sessionId: string
288
318
  holder: string
289
- indexes: number[]
319
+ claims: readonly { index: number; attempt: number }[]
290
320
  ttlMs: number
291
321
  expiresAtMs?: number
292
- }): Promise<number[]>
322
+ }): Promise<{ renewed: number[]; superseded: number[] }>
293
323
 
294
324
  completeAttempt(options: {
295
325
  sessionId: string
@@ -313,9 +343,9 @@ interface A2Log {
313
343
 
314
344
  | Method | Atomic effect |
315
345
  | --- | --- |
316
- | `append` | Write a consecutive batch. Events carrying core's internal `settled` flag receive `processedAt` in the same transaction, with no dispatch attempt. |
346
+ | `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). |
317
347
  | `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`. |
318
- | `renewClaims` | Extend the listed live claims still owned by the holder. Expired, completed, failed, and superseded claims are omitted and cannot be revived. |
348
+ | `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. |
319
349
  | `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`. |
320
350
  | `failAttempt` | Record one current caught failure, clear its claim, and dead-letter at `maxFailures`. A repeated failure acknowledgment is idempotent; stale attempts return `superseded`. |
321
351
 
@@ -328,11 +358,62 @@ means a top-level event.
328
358
  `lane` is the session-scoped serialized group resolved before append.
329
359
  `returnedEventIds` makes a lost completion
330
360
  acknowledgment recoverable without accepting a partial child batch. Lifecycle
331
- timestamps are adapter clock values for their atomic log operations, not exact
361
+ timestamps are adapter clock values for their atomic store operations, not exact
332
362
  database commit times.
333
363
  Built-in adapters persist these fields inside their existing atomic operations,
334
364
  with no extra backend round trip.
335
365
 
366
+ ### `A2Scheduler`
367
+
368
+ Custom scheduler adapters implement two methods:
369
+
370
+ | Method | Effect |
371
+ | --- | --- |
372
+ | `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
373
+ | `handler(...servers)` | return the route that receives drain and append tasks |
374
+
375
+ `handler` is the low-level adapter-author seam. Application routes call
376
+ `schedulerHandler(...servers)` from `experimental-a2/http`, which derives the
377
+ adapter from the servers and verifies their wiring before it delegates here.
378
+
379
+ `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask`. These task types,
380
+ plus `ScheduledEvent`, are exported from `experimental-a2/server`.
381
+
382
+ | Task | Fields |
383
+ | --- | --- |
384
+ | `SchedulerDrainTask` | `version: 1`, `kind: 'drain'`, `contract`, `sessionId`, required epoch-ms `dueAt` |
385
+ | `SchedulerAppendTask` | `version: 1`, `kind: 'append'`, stable `id`, `contract`, `sessionId`, required epoch-ms `dueAt`, readonly `events` |
386
+ | `ScheduledEvent` | fixed `id`, `type`, and JSON `payload` |
387
+
388
+ Drain tasks wake pending work and carry no application payload. Append tasks
389
+ carry the events for `session.schedule`.
390
+
391
+ Custom adapters deliver an authenticated, normalized append task through the
392
+ server helper:
393
+
394
+ ```ts custom-scheduler.ts
395
+ import {
396
+ deliverSchedulerAppend,
397
+ type DrainableServer,
398
+ type SchedulerAppendTask,
399
+ } from 'experimental-a2/server'
400
+
401
+ export async function deliver(
402
+ server: DrainableServer,
403
+ task: SchedulerAppendTask,
404
+ ) {
405
+ await deliverSchedulerAppend(server, task)
406
+ }
407
+ ```
408
+
409
+ Import `deliverSchedulerAppend` from `experimental-a2/server`. It verifies the
410
+ task and server contract match, revalidates the carried event inputs, and uses
411
+ the ordinary top-level append path. The target server must have a scheduler.
412
+ When the append leaves pending handler work, the helper resolves only after a
413
+ recovery watchdog is durable; a send failure keeps the provider delivery
414
+ retryable. Authenticate the transport and validate the task envelope before
415
+ calling it.
416
+
336
417
  ## Session
337
418
 
338
419
  ### `session.append(...events)`
@@ -347,14 +428,14 @@ The only way to move a session forward. Payloads are validated against
347
428
  the contract's schemas before anything is written. A multi-event append
348
429
  is atomic: all-or-nothing, consecutive positions, one transaction. Pass
349
430
  `id` to make an append idempotent across retries; re-sending an
350
- identical batch returns the original rows. (Events parsed by
351
- `parsePushBody` are accepted directly, the push-route path.) See
431
+ identical batch returns the original rows. (Events that arrived through
432
+ `handle`'s push lane are accepted directly.) See
352
433
  [Durability](/concepts/durability).
353
434
 
354
435
  After the write, A2 dispatches only event types with registered handlers.
355
436
  Other event types are already settled by the append itself. An unhandled
356
437
  append still starts session healing when older handled work is pending. The
357
- log reports that session-wide pending state as part of the atomic append, so
438
+ store reports that session-wide pending state as part of the atomic append, so
358
439
  this decision needs no follow-up read.
359
440
 
360
441
  ### `session.append.dispatch(...events)`
@@ -366,13 +447,13 @@ session.append.dispatch(
366
447
  ```
367
448
 
368
449
  Commits the same atomic batch, but sends pending work directly to configured
369
- recovery instead of starting an inline drain. The call awaits acceptance of an
370
- immediate recovery message. It throws before writing if the server has no
371
- recovery adapter.
450
+ scheduler instead of starting an inline drain. The call awaits acceptance of an
451
+ immediate scheduler message. It throws before writing if the server has no
452
+ scheduler adapter.
372
453
 
373
454
  If the queue send fails, the events are already durable. Give them explicit
374
455
  IDs and retry the same dispatch safely. A batch that leaves no pending handler
375
- work does not send a recovery message. Dispatch chooses how this append wakes
456
+ work does not send a scheduler message. Dispatch chooses how this append wakes
376
457
  the session; it does not reserve events for one worker. A drain that is already
377
458
  active may still claim newly eligible work first.
378
459
 
@@ -381,13 +462,57 @@ active may still claim newly eligible work first.
381
462
  does not expose `dispatch`. Its immediate children and atomically returned
382
463
  children become eligible in that drain.
383
464
 
465
+ ### `session.schedule(name, timing, ...events)`
466
+
467
+ `schedule` returns `Promise<void>` and is available on both root and handler
468
+ sessions. `name` must be non-empty. At least one event is required.
469
+ `ScheduleDelay`, `ScheduleTiming`, and `SessionSchedule` are exported from
470
+ `experimental-a2/server`.
471
+
472
+ | Timing | Meaning |
473
+ | --- | --- |
474
+ | `{ delay: '30s' }` | relative delay with unit `ms`, `s`, `m`, `h`, or `d` |
475
+ | `{ at: Date }` | absolute delivery time |
476
+
477
+ The template type checks the unit suffix. At runtime, the delay number must be
478
+ an unsigned base-10 decimal without leading zeros, finite, and greater than
479
+ zero. `0s`, signs, exponent notation, malformed durations, invalid dates, and
480
+ objects containing both timing fields are rejected. A root relative delay
481
+ starts from that call's wall clock. A handler relative delay starts from the
482
+ triggering event's durable `createdAt`, so retries preserve its delivery time.
483
+ Use `{ at }` when a retried root call needs one target time. The requested
484
+ target is the earliest useful delivery time. Adapters encode provider timing
485
+ in whole-second slots. An already-due target is sent immediately.
486
+
487
+ The stable task id derives from the contract, session, and name. Handler tasks
488
+ also include the triggering event id. Omitted event ids derive from that task
489
+ id and event position; explicit ids win. Reuse a name only for an identical
490
+ timing and event batch. Provider deduplication is an optimization. Stable event
491
+ ids make later duplicate deliveries converge in the store.
492
+
493
+ A2 snapshots payloads into plain JSON trees, then validates the event schemas
494
+ before sending. Encode dates as ISO strings and custom classes as plain data;
495
+ values such as `undefined`, bigint, functions, symbols, cycles, accessors, and
496
+ non-plain prototypes are rejected. The promise resolves after provider
497
+ acceptance, not after append. Shared object references become independent JSON
498
+ values. The task carries the snapshotted JSON schema input. At delivery, the
499
+ ordinary top-level append validates that input, so a schema
500
+ transform produces the stored payload from the original input. The events have
501
+ `cause: null` and enter normal handlers, lanes, state, and streams.
502
+
503
+ There is no cancellation, reschedule, cron, or timer-listing API. See
504
+ [Timers and delays](/guides/timers) for stale-timer guards, provider limits,
505
+ and deployment compatibility.
506
+
384
507
  ### `session.history()`
385
508
 
386
509
  ```ts
387
- session.history(): Promise<Event[]>
510
+ session.history(options?: { gte?: number; lte?: number }): Promise<Event[]>
388
511
  ```
389
512
 
390
- Every event in the session, oldest first. Always the raw log.
513
+ Raw events from the session, oldest first. `gte` and `lte` are inclusive event
514
+ indexes; omit a bound to leave that end of the log open.
515
+ Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
391
516
 
392
517
  ### `session.state(reducer)`
393
518
 
@@ -395,20 +520,88 @@ Every event in the session, oldest first. Always the raw log.
395
520
  session.state(reducer: Reducer<S>): Promise<{ state: S; index: number }>
396
521
  ```
397
522
 
398
- The log folded through a reducer. `index` is the position the state
399
- reflects. Hand it to the client to resume a stream from exactly there. The
400
- snapshot and event tail are one log operation; snapshot write-back runs in
401
- platform `waitUntil`. This read never dispatches handlers.
523
+ The log folded through one committed prefix. `index` is its exact boundary,
524
+ an append-order cursor rather than a handler-settlement marker. Hand it to the
525
+ client to resume there. Snapshot write-back uses platform `waitUntil`; this
526
+ read never dispatches handlers or waits for pending work.
402
527
 
403
528
  ### `session.stream(options?)`
404
529
 
405
530
  ```ts
406
- session.stream(options?: { startAt?: number }): AsyncIterable<Event>
531
+ session.stream(options?: { startAfter?: number }): AsyncIterable<Event>
532
+ session.stream(options: {
533
+ startAfter?: number
534
+ presence: true
535
+ }): AsyncIterable<Event | PresencePatch | PresenceSnapshot>
536
+ ```
537
+
538
+ A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
+ `startAfter: 20` begins with event 21.
540
+ Server-side only; `handle` exposes it over SSE, and over the multiplexed
541
+ socket when `upgrade` is set. Subscribing never dispatches handlers.
542
+
543
+ With `presence: true`, the feed yields one `PresenceSnapshot` first:
544
+ `{ snapshot }`, the current pruned map with each field's own `value`,
545
+ `seen`, and `at` stamp. Live presence patches then interleave with
546
+ events. The stream response sends both as named SSE frames, so clients
547
+ that don't know them skip them. The return type widens only under the
548
+ literal `presence: true`; without it, existing consumers keep
549
+ `AsyncIterable<Event>`. The option itself exists only on sessions of
550
+ contracts that declare `presence`; elsewhere it is a type error, not a
551
+ runtime throw. A `PresencePatch` is `{ participant, values, seen, at }`
552
+ with `values` typed by the contract's presence schemas (`null` marks a
553
+ cleared field).
554
+
555
+ ### `session.setPresence(patch)`
556
+
557
+ ```ts
558
+ session.setPresence(patch: {
559
+ participant: string
560
+ values: { [field]: payload | null } // partial; null clears a field
561
+ seen?: number
562
+ at?: number // the sender's LWW stamp, epoch ms; receipt time when absent
563
+ }): Promise<void>
407
564
  ```
408
565
 
409
- A live feed of the session's events, starting after `startAt`.
410
- Server-side only; expose it over SSE with `sseResponse`. Subscribing never
411
- dispatches handlers.
566
+ Validates each field against the contract's `presence` schemas, then
567
+ broadcasts. Last writer wins per participant per field, ordered by
568
+ `at`, the sender's stamp in epoch milliseconds (the browser client
569
+ stamps its sends automatically). When `at` is absent, receipt time
570
+ stands in: a stampless caller is a single writer, so receipt order is
571
+ its send order. No log row, no
572
+ dispatch, no scheduler arm; `history()` and `state()` never see it. A
573
+ bad payload throws `INVALID_PAYLOAD`; an unknown field throws
574
+ `UNKNOWN_PRESENCE_FIELD`; a non-finite or negative `at` throws a
575
+ `TypeError`. `seen` is the sender's stream frontier, the
576
+ stamp view code uses to reconcile presence against the fold (see
577
+ [Presence](/guides/presence#reconciling-the-planes-seen)). Values expire
578
+ by backend TTL when a participant goes silent (default 60 seconds; set
579
+ `createServer({ presence: { ttlMs } })` to change it). Expiry counts on
580
+ the storage clock, not the sender stamp, so a hostile stamp can only
581
+ vandalize its own field and still expires on schedule.
582
+
583
+ The patch is structurally the `presence` sibling of the push envelope;
584
+ `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
585
+ The API mirrors the wire.
586
+
587
+ The handler-scoped form `ctx.session.setPresence(...)` is the same
588
+ operation; `seen` defaults to the triggering event's `index`.
589
+
590
+ ### `session.presence()`
591
+
592
+ ```ts
593
+ session.presence(): Promise<{
594
+ [participant: string]: {
595
+ [field: string]: { value: unknown; seen: number; at: Date }
596
+ }
597
+ }>
598
+ ```
599
+
600
+ The current presence map, expired values pruned. A point-in-time read
601
+ for server renders; live consumers use `stream({ presence: true })`.
602
+ Each entry's `at` is the sender's stamp for when the value was set,
603
+ the last-writer-wins comparator; how long a value stays painted is
604
+ still view logic, decided at render time.
412
605
 
413
606
  ## Event
414
607
 
@@ -456,23 +649,70 @@ transition and the destination should adopt the same optimistic session.
456
649
  | `sessionId` | which session to subscribe to |
457
650
  | `initialState` | server-rendered state |
458
651
  | `initialIndex` | the fold's frontier, where the stream resumes |
459
- | `initialEvents` | optional server-rendered history through that frontier |
652
+ | `initialEvents` | optional earlier raw events for a history UI |
653
+ | `participant` | this client's presence identity; required to call `setPresence` |
460
654
 
461
655
  Opens the stream on mount, closes it on unmount, reconnects with
462
- backoff from the current frontier.
656
+ backoff from the current frontier. `participant` binds at the session's
657
+ first resolution; a changed provider prop is ignored for the session's
658
+ runtime lifetime.
659
+
660
+ `initialState` and `initialIndex` are the complete server-to-client handoff.
661
+ The stream resumes after `initialIndex`, including events appended between the
662
+ server render and the connection. `initialEvents` only preloads the raw
663
+ `events` feed for a UI that needs entries from before that frontier. It does
664
+ not affect state hydration or stream resumption.
463
665
 
464
666
  ### `useSession()`
465
667
 
466
668
  ```ts
467
- const { state, push, events, index, connection } = useSession()
669
+ const { state, push, events, index, loadHistory, history, connection,
670
+ presence, setPresence } = useSession()
468
671
  ```
469
672
 
470
- `state` folds live events through the shared reducer, `events` is the
471
- raw observed feed, and `index` is the stream frontier, the
472
- `lastSeenIndex` for [cancellation](/guides/cancellation).
673
+ `state` starts from the initial snapshot and folds live events through the
674
+ shared reducer. `events` is the raw event feed: observed, explicitly
675
+ seeded, or backscrolled. Unless `initialEvents` seeds earlier entries or
676
+ `loadHistory` fetches them, it begins after `initialIndex`. `index` is
677
+ the stream frontier, the `lastSeenIndex` for
678
+ [cancellation](/guides/cancellation).
679
+
680
+ `loadHistory({ before?, limit? })` backscrolls: it fetches a bounded
681
+ slice of the log from below the frontier (the same route, `gte`/`lte`
682
+ query parameters) and merges it into `events`, deduped, ordered, and
683
+ shared across every handle of the session. It resolves with the events
684
+ in the requested range. Defaults walk backward 50 at a time from the
685
+ oldest loaded event; `before` is an exclusive upper bound. After a
686
+ hydrate jump (returning to a session whose frontier advanced while
687
+ away), default paging still continues from the oldest loaded event;
688
+ pass an explicit `before` to fill the gap between the old feed and the
689
+ new frontier. It never touches `state` or the optimistic overlay. Calls
690
+ serialize per session and already-loaded ranges are not refetched.
691
+ `history` is the progress: `{ loading, complete, oldestLoaded }`, where
692
+ `complete` means the feed reaches index 1 (or the log is empty). The `ws` api has no history lane; there `loadHistory`
693
+ throws a `TypeError`.
694
+
695
+ `presence` is the replicated ephemeral map,
696
+ `Record<participantId, { [field]: { value, seen, at } }>`, including
697
+ this client. `setPresence(values)` is fire-and-forget: validated
698
+ locally, merged into the local map immediately, coalesced on the wire
699
+ to a fixed cadence (a leading send immediately, then at most one
700
+ merged send per interval; later values win per field), stamped with
701
+ the frontier at send time as `seen`. Parallel sends are safe: merges
702
+ are last-writer-wins by `at`, and the client's stamps are monotonic
703
+ per session (wall-seeded, guarded so overlapping sends can never
704
+ invert). No ack, no `confirmed`, no retry.
705
+
706
+ Both members exist only when the contract declares `presence`; their
707
+ value and field types come from its schemas, through the reducer, with
708
+ no type arguments (the reducer is the client's typed handle on the
709
+ contract; it still never folds presence). `setPresence` requires the
710
+ provider's `participant`. If the map holds only your own echo with
711
+ participants active, the GET route forgot `presence: true`. See
712
+ [Presence](/guides/presence).
473
713
 
474
714
  `push` appends optimistically: validated locally, rolled back on
475
- rejection, retried only for `LOG_UNAVAILABLE`. Awaiting it gives the
715
+ rejection, retried only for `STORE_UNAVAILABLE`. Awaiting it gives the
476
716
  server ack; the same result carries `confirmed`, a lazy promise for
477
717
  the later moment when the live stream has delivered the batch back and
478
718
  the view shows server truth:
@@ -485,6 +725,12 @@ await result.confirmed // the stream round-trip: ack→stream latency
485
725
  const roundtripMs = performance.now() - t0
486
726
  ```
487
727
 
728
+ Overlapping calls through the same session client update the view
729
+ immediately, then enter the transport in invocation order. Each call waits
730
+ only for the previous call's ack or final rejection, not for `confirmed`.
731
+ Other session IDs and clients remain concurrent; they converge on the log's
732
+ durable order.
733
+
488
734
  A rejected push rejects both promises with the same `A2Error`;
489
735
  `confirmed` is materialized only when accessed, so ignoring it costs
490
736
  nothing. It also reads as intent: `await push(...).confirmed` is
@@ -527,14 +773,47 @@ definition exposes only `contract` and `reducer`.
527
773
 
528
774
  Built-in event names cannot be replaced by `options.events`.
529
775
 
776
+ ### `handlerContext(agent)`
777
+
778
+ Reads the durable A2 handler context ambient to the current tool
779
+ execution. Tools are plain AI SDK `tool()` definitions; A2 passes their
780
+ options through to the AI SDK without interpreting them and runs each
781
+ local `execute` inside an ambient context scope. Call
782
+ `handlerContext(agent)` (or pass a contract) anywhere below `execute`,
783
+ including awaited helpers and async iteration. The argument types the
784
+ result and is verified against the executing server's contract: a
785
+ mismatched agent, or a call outside any tool execution, throws a
786
+ `TypeError`.
787
+
788
+ The returned `AgentToolContext<D>` is a union of the current A2
789
+ handler's `event`, `attempt`, `session`, and `signal`. Automatic
790
+ execution has an `ai.tool.called` event; execution after approval has an
791
+ `ai.approval.responded` event. Narrow `ctx.event.type` when
792
+ event-specific payload fields matter. Both variants have a session typed
793
+ from the supplied agent's complete event vocabulary. The agent does not
794
+ hold a scheduler. Calls such as `ctx.session.schedule()` use the
795
+ scheduler of the server executing the tool.
796
+
797
+ The context is runtime capability, not event data. A2 creates it for the
798
+ current attempt, never persists it, and never sends it to the model or a
799
+ provider-executed tool. Each retry reads that attempt's fresh context.
800
+ Model-phase tool options (`contextSchema`, dynamic descriptions,
801
+ `needsApproval` predicates, input lifecycle callbacks) are the AI SDK's
802
+ own; A2 forwards them untouched, and durable execution passes no AI SDK
803
+ `context` option. `needsApproval` and `generation.toolApproval`
804
+ predicates run during the model step and are re-evaluated on recovery
805
+ for the same durable tool call, so keep them deterministic over the tool
806
+ input. Model-phase hooks see tool definitions without local `execute`
807
+ functions.
808
+
530
809
  ### `inputs`
531
810
 
532
811
  Pure typed inputs for `append()` and `push()`:
533
812
 
534
813
  | Input | Events |
535
814
  | --- | --- |
536
- | `inputs.message(message)` | `ai.message.created`; the server schedules a user turn |
537
- | `inputs.seed(message)` | `ai.message.created` for a trusted server append |
815
+ | `inputs.message(message, { generate? })` | `ai.message.created`; pass `generate: false` to record context without scheduling |
816
+ | `inputs.seed(message)` | `ai.message.created` with `generate: false`; non-user roles require a trusted append |
538
817
  | `inputs.approval(response)` | `ai.approval.responded` only |
539
818
  | `inputs.input(response)` | `ai.input.responded` only |
540
819
  | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
@@ -547,8 +826,20 @@ when an application uses them. Input event ids are stable for the interaction
547
826
  they describe, so a lost append acknowledgment can be resent safely. Browser
548
827
  ingress accepts user messages, approval and input responses, interruptions,
549
828
  and explicit retries. Only built-in server handlers append generation requests
550
- and AI lifecycle events. `inputs.seed()` and `inputs.requestInput()` are for
551
- trusted server appends.
829
+ and AI lifecycle events. `inputs.requestInput()` and non-user messages built
830
+ with `inputs.seed()` are for trusted server appends.
831
+
832
+ `inputs.message(message, { generate: false })` is valid browser input. It keeps
833
+ the user message in `AIState.messages` without starting a model turn. The next
834
+ user message that allows generation includes passive user messages before it
835
+ in model context. Passive user messages after that trigger wait for a later
836
+ generating message. Omitting the option preserves the default scheduling
837
+ behavior and the existing `{ message }` event payload. `inputs.seed()` writes
838
+ the same passive flag. Trusted assistant and system seeds are context, not user
839
+ queue cutpoints. The server never schedules them, regardless of the `generate`
840
+ field. Model output is generation progress rather than another
841
+ `ai.message.created` fact, so an assistant response cannot recursively start a
842
+ new turn.
552
843
 
553
844
  Approval and input request/response payloads require the active
554
845
  `generationId`. Clients copy it from the pending request, which prevents a
@@ -561,10 +852,22 @@ delayed response from satisfying a newer model step.
561
852
  `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
562
853
  continuation before another model step starts.
563
854
 
564
- `ai.message.interrupted` carries `{ messageId, generationId?, reason?,
565
- lastSeenIndex? }`. Omit `generationId` only while the matching response is in
566
- the requested phase and `activeGeneration` does not exist yet. Once a
567
- generation starts, include its id to fence delayed interruption actions.
855
+ `ai.message.interrupted` carries either `{ messageId, requestId, reason?,
856
+ lastSeenIndex? }` or `{ messageId, generationId, reason?, lastSeenIndex? }`.
857
+ Exactly one owner ID is required. Clients copy `requestId` from
858
+ `activeRequestId` before generation start and switch to the observed
859
+ `activeGeneration.generationId` after start. A request-owned interruption
860
+ remains valid if that same request starts before the append commits. A delayed
861
+ interruption for an older owner is a projection and cancellation no-op.
862
+ The message and owner IDs are validated as one pair. If either ID does not
863
+ identify that same response owner, the interruption is stale and is a no-op.
864
+
865
+ An accepted interruption terminally fences its request or generation. Later
866
+ generation, tool, approval, input, and compaction events remain in raw history
867
+ but cannot alter `AIState`, satisfy pending work, or reactivate the response.
868
+ Completed tool results at or before `lastSeenIndex` remain completed. A final
869
+ tool result that races an ordinary generation failure remains authoritative in
870
+ either commit order, while the generation stays failed.
568
871
 
569
872
  ### `events` and `createEvents(options?)`
570
873
 
@@ -581,15 +884,24 @@ createReducer({ contract, name? }): Reducer<AIState>
581
884
  Builds the standard AI projection for a compatible contract. `AIState`
582
885
  contains session lifecycle, messages, generation status, pending approvals
583
886
  and input, tool activity, compaction, usage, the last error, and
584
- `activeRequestId` and `activeResponseMessageId`, plus
585
- `responseGenerationIds: Record<string, string>`.
887
+ `activeRequestId`, `activeResponseMessageId`,
888
+ `responseGenerationIds: Record<string, string>`, and terminal request and
889
+ generation ownership fences.
586
890
  `activeRequestId` is the server-authorized generation request and fences
587
891
  delayed requests before their generation starts. `activeResponseMessageId`
588
892
  identifies the requested response until `activeGeneration` exists.
589
- `activeProjection` holds the indexed chunk/tool frontier only while a
590
- generation is active; terminal events clear it. `responseGenerationIds` keeps
591
- the latest generation owner for each response message, so late events from a
592
- superseded owner cannot alter the projection. Extension events are ignored.
893
+ `activeProjection` holds the indexed generation frontier while a generation is
894
+ active, after a generation step completes while its response waits on tool,
895
+ approval, or input barriers, and after a generation fails while it awaits retry
896
+ or interruption. Response completion, interruption, supersession, retry, or a
897
+ later generation clears or replaces it. `responseGenerationIds` keeps the
898
+ latest generation owner for each response message, so late events from a
899
+ superseded owner cannot alter the projection. `terminalRequestIds` and
900
+ `terminalGenerations` preserve accepted completion, failure, interruption,
901
+ and supersession fences across snapshots and recovery. They are optional
902
+ snapshot-compatible fields with the shapes `Record<string, true>` and
903
+ `Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
904
+ Extension events are ignored. The default reducer name is `a2.ai.state.v8`.
593
905
 
594
906
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
595
907
 
@@ -609,10 +921,11 @@ createAgentServer({
609
921
  instructions?,
610
922
  generation?,
611
923
  maxSteps?,
924
+ messageMetadata?,
612
925
  generate?,
613
926
  compaction?,
614
927
  progress?,
615
- log?, recovery?, telemetry?, handlers?,
928
+ store?, scheduler?, telemetry?, handlers?,
616
929
  }): A2Server
617
930
  ```
618
931
 
@@ -626,12 +939,23 @@ async resolvers.
626
939
 
627
940
  `generation` contains per-step settings such as `temperature`,
628
941
  `maxOutputTokens`, `topP`, provider options, and tool approval policy. A2 owns
629
- the one-step stop condition, local tool execution, and continuation. `maxSteps`
630
- limits one complete assistant response and defaults to 20. Support for
631
- individual model settings depends on the selected model and provider.
632
- `generation` excludes `stopWhen`, tool execution callbacks, tool callers, tool
633
- context, sandbox execution, and the tool approval secret. Model-step timeouts
634
- remain available; tool-execution timeouts do not.
942
+ the one-step stop condition, local tool execution, and continuation. Responses
943
+ have no step limit by default. Set `maxSteps` to a positive integer to limit one
944
+ complete assistant response. Support for individual model settings depends on
945
+ the selected model and provider. `generation` excludes `stopWhen`, tool
946
+ execution callbacks, tool callers, sandbox execution, and the tool approval
947
+ secret. Everything else, including `toolsContext`, passes through to the AI SDK
948
+ untouched. A local tool reads its durable context with `handlerContext()` when
949
+ its handler executes. Model-step timeouts remain available; tool-execution
950
+ timeouts are not.
951
+
952
+ `messageMetadata(context)` synchronously maps each default AI SDK stream part
953
+ to the metadata type of the agent's `UIMessage`. The context includes the
954
+ generation state and the current `TextStreamPart`. Metadata returned for
955
+ standard `start` and `finish` parts is recorded with generation progress and
956
+ shallow-merged onto the projected assistant message. Use the `finish` part's
957
+ `totalUsage` to retain final token usage. A custom `generate` function owns its
958
+ metadata chunks and does not use this mapper.
635
959
 
636
960
  `generate(context)` optionally replaces the default AI SDK generation. It
637
961
  receives messages, the resolved model and instructions, tools, generation
@@ -675,6 +999,13 @@ When selected, both the request and the replacement messages enter the log.
675
999
 
676
1000
  This entry point is server-only and resolves to a throwing browser stub.
677
1001
 
1002
+ ### `runWithHandlerContext(ctx, fn)`
1003
+
1004
+ The testing seam for context-reading tools: runs `fn` with `ctx`
1005
+ installed as the ambient handler context, so a tool's `execute` can be
1006
+ called directly in tests. Inside `fn`, `handlerContext()` returns `ctx`
1007
+ for any agent argument.
1008
+
678
1009
  ### `createHandlers(options)`
679
1010
 
680
1011
  Returns the built-in A2 handler table without constructing a server. Spread
@@ -691,7 +1022,7 @@ handler. Custom assemblies pass `validateAgentPush` as
691
1022
 
692
1023
  Accepts the browser interaction allowlist: user messages, approval and input
693
1024
  responses, interruptions, and explicit retries. It rejects server-authored
694
- scheduling and lifecycle events, trusted seed messages, and input requests.
1025
+ scheduling and lifecycle events, seeded non-user messages, and input requests.
695
1026
  `createAgentServer()` installs it automatically.
696
1027
 
697
1028
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
@@ -702,40 +1033,127 @@ examples.
702
1033
  ```ts
703
1034
  createClient(options: {
704
1035
  reducer: Reducer
705
- api: string
1036
+ api: ClientApi
706
1037
  gcTime?: number // idle session lifetime; 5 minutes by default
707
1038
  }): A2Client
1039
+
1040
+ type ClientApi =
1041
+ | string // one route: GET SSE stream + POST push
1042
+ | { type: 'http'; push: string; stream: string } // split routes
1043
+ | { type: 'ws'; url: string } // one socket, both directions
708
1044
  ```
709
1045
 
710
1046
  The framework-agnostic session client `experimental-a2/react` is built on: the SSE
711
1047
  subscription with frontier resume and reconnection, the optimistic push
712
1048
  queue with ack/rollback, and the local fold. `client.session(id, {
713
- initialState?, initialIndex?, initialEvents? })` returns a handle with
714
- `getSnapshot()`/`subscribe()` (the `useSyncExternalStore` contract),
715
- `push()`, `connect()`, and `close()`. Snapshots carry `state`, `events`,
716
- `index`, and `connection` (the same fields `useSession` exposes), and
717
- `push` returns the same ack-then-`confirmed` result. Use it directly
718
- from any other framework, or none.
1049
+ initialState?, initialIndex?, initialEvents?, participant? })` returns a
1050
+ handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1051
+ contract), `push()`, `loadHistory()`, `connect()`, and `close()`.
1052
+ Snapshots carry `state`, `events`, `index`, `history`, and `connection`
1053
+ (the same fields `useSession` exposes), and `push` returns the same
1054
+ ack-then-`confirmed` result. On contracts that declare `presence` the handle also carries
1055
+ `setPresence()` and snapshots carry the `presence` map, exactly like
1056
+ the hook; `participant` is the identity `setPresence` sends under. Use
1057
+ it directly from any other framework, or none.
719
1058
 
720
1059
  Within one `A2Client`, repeated `session(id)` calls return the same live
721
- handle. `initialEvents` seeds the raw feed for SSR. A later call with a further
722
- server-rendered frontier advances the handle, merges its history, and does not
723
- drop pending optimistic events; a stale render cannot move it backward. Idle
724
- handles are evicted after `gcTime`. This memory layer is separate from
725
- `experimental-a2/cache-indexeddb`: memory preserves identity across route transitions,
726
- while IndexedDB preserves the replica across reloads.
1060
+ handle. A later call with a further server-rendered frontier advances the
1061
+ handle and does not drop pending optimistic events; a stale render cannot move
1062
+ it backward. Optional `initialEvents` seed earlier raw events for history UIs
1063
+ and merge into that feed. They do not affect the folded state or stream
1064
+ frontier. Idle handles are evicted after `gcTime`. This memory layer is
1065
+ separate from `experimental-a2/cache-indexeddb`: memory preserves identity
1066
+ across route transitions, while IndexedDB preserves the replica across reloads.
727
1067
 
728
1068
  ## `experimental-a2/http`
729
1069
 
1070
+ ### `handle(server, options?)`
1071
+
1072
+ ```ts
1073
+ handle(server: A2Server, options?: {
1074
+ before?(args: { request: Request; intent: A2Intent }):
1075
+ Response | undefined | void | Promise<Response | undefined | void>
1076
+ after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
1077
+ Response | undefined | void | Promise<Response | undefined | void>
1078
+ upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
1079
+ presence?: boolean // interleave presence on every stream lane
1080
+ deadline?: number // epoch ms: close sockets cleanly before it
1081
+ }): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
1082
+
1083
+ type A2Intent =
1084
+ | { type: 'ws-upgrade' }
1085
+ | { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
1086
+ | { type: 'history'; sessionId: string; gte: number; lte: number }
1087
+ | { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
1088
+
1089
+ type A2Outcome =
1090
+ | { type: 'stream' }
1091
+ | { type: 'history'; covered: boolean; events: Event[] }
1092
+ | { type: 'push'; appended: Event[] }
1093
+ ```
1094
+
1095
+ The session route pair as one call: `export const { GET, POST } =
1096
+ handle(server)` in a route module (any framework speaking
1097
+ `(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
1098
+ stream, resumed after the `index` query parameter, with a `: connected`
1099
+ prelude, a `: ping` heartbeat every 15s, and a clean close one second
1100
+ before an ambient Vercel invocation deadline when available; presence
1101
+ patches ride as named frames when `presence: true`. A `GET` with
1102
+ `gte`/`lte` query parameters is a history slice: the closed log range
1103
+ as JSON wire events, the read `loadHistory` rides. `POST` is the push
1104
+ envelope `{ sessionId, events, presence? }`, answered with the appended
1105
+ events. A `GET` carrying an upgrade header becomes the multiplexed
1106
+ WebSocket when `options.upgrade` is present, and answers `426` when it
1107
+ is not.
1108
+
1109
+ Parsing is protocol, hooks are policy. A request that fails to parse
1110
+ (missing `sessionId`, malformed bounds, a bad push envelope) answers
1111
+ `INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
1112
+ parsed intent, HTTP requests and socket frames alike; over the socket,
1113
+ each subscribe arrives as a `stream` intent and each push as a `push`
1114
+ intent, with `request` always the original upgrade Request. Returning a
1115
+ Response short-circuits: over HTTP it is the response, verbatim; over
1116
+ the socket it is translated into the wire's own vocabulary (a denied
1117
+ subscribe answers `unsubscribed`, a denied push a non-retryable error
1118
+ ack), because a Response cannot cross a socket.
1119
+
1120
+ `after` runs only where the library produced an HTTP response: never
1121
+ after a short-circuit, never for `ws-upgrade` or socket frames. It may
1122
+ mutate `response.headers` in place or return a replacement Response.
1123
+ `outcome.covered` on a history read means the closed range came back
1124
+ fully covered (`events.length === lte - gte + 1`): an immutable slice
1125
+ of the append-only log, safe to cache under whatever policy your
1126
+ `after` applies. The history response carries no cache headers of its
1127
+ own.
1128
+
1129
+ The socket is one connection for all of a client's sessions:
1130
+ `subscribe`/`unsubscribe` frames open and close per-session lanes at
1131
+ their own resume frontiers, `sessionId` tags route pushes, presence,
1132
+ and acks, and a lane ending or failing answers `unsubscribed` without
1133
+ taking the socket down. See [Transports](/guides/transports).
1134
+
1135
+ ### The rest of the entry
1136
+
730
1137
  | Helper | What it does |
731
1138
  | ----------------------- | ------------------------------------------------------------------------------ |
732
- | `parsePushBody(req)` | validates the push envelope `{ sessionId, events }`, throws `INVALID_PAYLOAD` |
733
- | `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude and a `: ping` heartbeat every 15s |
734
- | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
1139
+ | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1140
+ | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
735
1141
  | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
736
1142
 
737
- Together the last two are the `A2Error` wire format that `push` and the
738
- push route share. See [Errors](/reference/errors#over-the-wire).
1143
+ `schedulerHandler(...servers)` is the application-facing scheduler route. It
1144
+ requires at least one A2 server. Every server must have a scheduler, use the
1145
+ exact same scheduler instance, and have a unique contract name. Non-A2 values
1146
+ also fail. These checks throw before the request handler is returned, so bad
1147
+ wiring fails when the route module loads. The helper then delegates delivery
1148
+ to the shared adapter's `A2Scheduler.handler(...)` method.
1149
+
1150
+ Different scheduler instances use different routes. Match each QStash route to
1151
+ that instance's resolved `url`; additional QStash routes pass an explicit
1152
+ `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1153
+
1154
+ `errorResponse` and `deserializeError` are the `A2Error` wire format
1155
+ that `push` and the push lane share. See
1156
+ [Errors](/reference/errors#over-the-wire).
739
1157
 
740
1158
  ## `experimental-a2/cache-indexeddb`
741
1159
 
@@ -767,7 +1185,7 @@ Four spans, all carrying `a2.contract` and `a2.session_id`:
767
1185
 
768
1186
  | Span | Wraps |
769
1187
  | ----------- | ------------------------------------------------------ |
770
- | `a2.append` | validation + the log write (one batch) |
1188
+ | `a2.append` | validation + the store write (one batch) |
771
1189
  | `a2.drain` | one drain pass over a session's backlog |
772
1190
  | `a2.event` | one claimed dispatch of one event |
773
1191
  | `a2.state` | one `state()` read: snapshot-plus-tail load + fold |
@@ -783,7 +1201,7 @@ alert on are all mid-span.
783
1201
  | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
784
1202
  | `a2.append.types` | `a2.append` | start | comma-joined event types |
785
1203
  | `a2.append.count` | `a2.append` | start | batch size |
786
- | `a2.append.armed` | `a2.append` | mid | `false` when the recovery arm failed and this append degraded to append-driven healing |
1204
+ | `a2.append.armed` | `a2.append` | mid | `false` when the scheduler arm failed and this append degraded to append-driven healing |
787
1205
  | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
788
1206
  | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
789
1207
  | `a2.event.type` | `a2.event` | start | the event's type |
@@ -792,7 +1210,7 @@ alert on are all mid-span.
792
1210
  | `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
793
1211
  | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
794
1212
  | `a2.event.handled` | `a2.event` | start | normally `true`; `false` when a custom-adapter row has no handler |
795
- | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` |
1213
+ | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` \| `surrendered` |
796
1214
  | `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
797
1215
  | `a2.state.reducer` | `a2.state` | start | the reducer's name |
798
1216
  | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
@@ -814,6 +1232,54 @@ log.
814
1232
  renames and additions are breaking for dashboards, and are called out in the
815
1233
  package's `CHANGELOG.md`.
816
1234
 
1235
+ ## `experimental-a2/devtools`
1236
+
1237
+ The shared, isomorphic protocol for browser Devtools, the CLI, and `.a2log`
1238
+ captures.
1239
+
1240
+ | Export | Contract |
1241
+ | --- | --- |
1242
+ | `DEVTOOLS_PROTOCOL_VERSION` | Current HTTP wire protocol version |
1243
+ | `DEVTOOLS_CAPTURE_VERSION` | Current `.a2log` record format version |
1244
+ | `DEVTOOLS_CAPTURE_MEDIA_TYPE` | `application/x-ndjson` |
1245
+ | `DEVTOOLS_SESSION_PAGE_LIMIT` | Default event count requested per session page |
1246
+ | `DEVTOOLS_SESSION_PAGE_MAX_LIMIT` | Maximum event count accepted per session page |
1247
+ | `DEVTOOLS_CAPABILITIES` | Protocol resources and capture format advertised by the server |
1248
+ | `devtoolsSessionRevision({ events, snapshots })` | Compute the lifecycle invalidation token after assembling pages |
1249
+ | `encodeDevtoolsCapture(detail, { capturedAt? })` | Encode one session detail as exact NDJSON and add its SHA-256 footer |
1250
+ | `parseDevtoolsCapture(value)` | Parse NDJSON text or bytes into a `DevtoolsCapture` while retaining unknown fields on known records |
1251
+ | `verifyDevtoolsCapture(capture)` | Verify versions, record shapes, session identity, indexes, counts, dates, and the SHA-256 digest |
1252
+
1253
+ The wire types are `DevtoolsWireSessionSummary`, `DevtoolsWireEvent`,
1254
+ `DevtoolsWireSnapshot`, `DevtoolsContractsResponse`,
1255
+ `DevtoolsSessionsResponse`, `DevtoolsSessionDetail`, `DevtoolsSessionPage`, and
1256
+ `DevtoolsCapabilities`. Capture records use `DevtoolsCaptureManifest`,
1257
+ `DevtoolsCaptureEvent`, `DevtoolsCaptureSnapshot`,
1258
+ `DevtoolsCaptureFooter`, and `DevtoolsCaptureRecord`. A parsed
1259
+ `DevtoolsCapture` exposes the same canonical sequence through `records`.
1260
+
1261
+ ```ts test/verify-a2log.ts
1262
+ import { readFile } from 'node:fs/promises'
1263
+ import {
1264
+ parseDevtoolsCapture,
1265
+ verifyDevtoolsCapture,
1266
+ } from 'experimental-a2/devtools'
1267
+
1268
+ export async function verifyA2Log(path: string) {
1269
+ const capture = parseDevtoolsCapture(await readFile(path))
1270
+ await verifyDevtoolsCapture(capture)
1271
+ return capture
1272
+ }
1273
+ ```
1274
+
1275
+ The manifest records the contract, session, revision, capture time, protocol
1276
+ version, and capture version. Event records contain the exact durable payload
1277
+ and operational bookkeeping. Snapshot records contain metadata but never
1278
+ cached reducer state. The final `end` record contains counts, the highest event
1279
+ index, and a digest of every preceding encoded record. The session revision is
1280
+ an invalidation token, not the integrity digest. Encoding rejects durable
1281
+ values that canonical JSON would coerce or omit.
1282
+
817
1283
  ## `experimental-a2/devtools/server`
818
1284
 
819
1285
  ```ts
@@ -838,14 +1304,274 @@ from stored events. The initial page response includes the selected dashboard
838
1304
  data so the browser does not need a contracts, sessions, and detail request
839
1305
  waterfall. It never drains or heals a session.
840
1306
 
841
- Without `authorize`, the handler is available outside production and returns
842
- 404 in production. Returning `false` from `authorize` also returns 404.
843
- Returning a `Response` passes that response through, which supports redirects
844
- and authentication challenges.
1307
+ Every route is a `GET`. The versioned resources are `capabilities`,
1308
+ `contracts`, `sessions`, `session`, `watch`, and `export` under the handler's
1309
+ `_a2/` path. `export` returns an integrity-checkable `.a2log` with the media
1310
+ type from `DEVTOOLS_CAPTURE_MEDIA_TYPE`.
1311
+
1312
+ `session` without pagination parameters returns one complete detail. Add a
1313
+ positive `limit` to receive a `DevtoolsSessionPage`. Its `throughIndex` is the
1314
+ inclusive event frontier frozen by the first request. Pass its opaque `cursor`
1315
+ back until the cursor is null. Snapshot metadata appears on the first page
1316
+ only. The cursor is bound to its contract and session, and the server rejects
1317
+ reuse against another log. The advertised maximum page size is clamped by the
1318
+ server.
1319
+
1320
+ The browser, CLI, watch loop, and export route walk these bounded pages for
1321
+ you. They reject a gap or early end. An exact export includes every event
1322
+ through one finite frontier, even when a backend provider limits one range
1323
+ response. Events appended during the walk belong to a later read.
1324
+
1325
+ Without `authorize`, the handler is available only when `NODE_ENV` is exactly
1326
+ `development`. Any other value, including unset, returns 404. When `authorize`
1327
+ is present, only a literal `true` grants access. Returning `false` also returns
1328
+ 404. Returning a `Response` passes that response through, which supports
1329
+ redirects and authentication challenges. The mounted handler is the
1330
+ authorization boundary for the browser, CLI, SSE stream, and exact capture
1331
+ download. A2 does not add a separate Devtools credential store.
1332
+
1333
+ The built-in memory, SQLite, Postgres, and Redis stores support inspection.
1334
+ Custom `A2Store` implementations can omit the optional `inspect` interface; the
1335
+ dashboard returns 501 for those logs. A custom inspection implementation may
1336
+ add `readEvents(sessionId, { afterIndex, throughIndex?, limit })` for efficient
1337
+ bounded reads. It returns `{ events, throughIndex }`, where the first call
1338
+ freezes the inclusive frontier and continuations preserve it. Without this
1339
+ method, A2 reads the complete log for each page and slices it in memory.
1340
+
1341
+ ## `experimental-a2/scheduler-vercel`
1342
+
1343
+ `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
1344
+
1345
+ ```ts server/scheduler-vercel.ts
1346
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
1347
+
1348
+ export const scheduler = vercelQueues()
1349
+ ```
1350
+
1351
+ | Option | Type | Default |
1352
+ | --- | --- | --- |
1353
+ | `topic` | `string` matching `[A-Za-z0-9_-]+` | `'a2'` |
1354
+ | `scheduledAppends` | `'deployment' \| 'deploymentless'` | `'deployment'` |
1355
+ | `transport` | `QueueTransport` | lazy `@vercel/queue` client |
1356
+ | `clock` | `{ now(): Date }` | system clock |
1357
+
1358
+ The default pins recovery watchdogs and scheduled appends to the deployment
1359
+ that publishes them. Recovery always remains pinned. Set `scheduledAppends` to
1360
+ `'deploymentless'` only when every eligible deploymentless consumer for the
1361
+ topic can decode pending tasks and validate their events. This option removes
1362
+ deployment affinity. It does not select the latest production deployment.
1363
+
1364
+ The adapter retains every task for seven days and accepts delays through six
1365
+ days, leaving the final day for delivery and retries. `transport` and `clock`
1366
+ are advanced injection seams for tests and custom delivery. An injected
1367
+ transport receives the selected deployment affinity as an optional fourth send
1368
+ argument while its message remains an exact `SchedulerTask`. It must honor that
1369
+ hint for `scheduledAppends: 'deploymentless'` to affect its provider-specific
1370
+ routing. The built-in transport adds its callback-routing metadata only after
1371
+ this injection seam.
1372
+
1373
+ ## `experimental-a2/scheduler-qstash`
1374
+
1375
+ `qstash(options?)` returns an `A2Scheduler` backed by QStash HTTP delivery.
1376
+ Vercel Queues remains the recommended default on Vercel. Use QStash when you
1377
+ want a scheduler that calls a stable public endpoint.
1378
+
1379
+ ```ts server/scheduler-qstash.ts
1380
+ import { qstash } from 'experimental-a2/scheduler-qstash'
1381
+
1382
+ export const scheduler = qstash()
1383
+ ```
1384
+
1385
+ | Option | Type | Default |
1386
+ | --- | --- | --- |
1387
+ | `url` | `string` | conventional local or Vercel callback; required for process-less runtimes, other production, and custom routes |
1388
+ | `token` | `string` | `QSTASH_TOKEN` |
1389
+ | `currentSigningKey` | `string` | `QSTASH_CURRENT_SIGNING_KEY` |
1390
+ | `nextSigningKey` | `string` | `QSTASH_NEXT_SIGNING_KEY` |
1391
+ | `headers` | `Readonly<Record<string, string>>` | no additional callback headers |
1392
+ | `client` | `QStashPublishingClient` | lazy `@upstash/qstash` client |
1393
+ | `retries` | non-negative integer | the QStash provider default |
1394
+ | `maxBodyBytes` | positive integer | `1_048_576` (1 MiB) for callbacks and reserved outbound bodies |
1395
+ | `transport` | `QStashTransport` | lazy `@upstash/qstash` client and receiver |
1396
+ | `clock` | `{ now(): Date }` | system clock |
1397
+
1398
+ `headers` adds static headers to every callback delivery, including recovery
1399
+ successors. A2 validates and snapshots the record when `qstash()` runs, then
1400
+ asks QStash to redact every configured value from its dashboard and API. QStash
1401
+ still receives, stores, and delivers the original values. Header names are
1402
+ case-insensitive. Provider-owned `Upstash-*` headers, `Content-Type*`, HTTP
1403
+ framing headers, and A2's managed Vercel identity headers are reserved.
1404
+
1405
+ `client` accepts the structural `publishJSON(...)` part of a configured
1406
+ `@upstash/qstash` `Client`. A2 still creates the `Receiver` lazily from the
1407
+ signing-key options. `client` cannot be combined with `token` or `transport`.
1408
+ Use it to configure the QStash API endpoint, publish-request retry policy, or
1409
+ telemetry. Use A2's `headers` option for callback credentials. Client-global
1410
+ callback headers and redaction are outside A2's merge contract. The injected
1411
+ client receives raw callback credentials, including an automatic preview
1412
+ identity, and must honor the per-publish redaction request.
1413
+
1414
+ `transport` and `clock` are advanced injection seams for tests and custom
1415
+ delivery. A `QStashTransport` implements `publish(request)` and
1416
+ `verify(request)`. Publish requests contain the callback `url`, a complete
1417
+ `SchedulerTask` body, optional callback `headers`, optional `redact.header`
1418
+ names, optional Unix-seconds `notBefore`, and a deterministic
1419
+ `deduplicationId`. Store-outage successors add provider-private generation
1420
+ metadata to that body. Custom transports must preserve the body, deliver every
1421
+ header, and apply every redaction request. An explicit `retries` value is
1422
+ forwarded; omission lets QStash use its provider default. Verify requests
1423
+ contain the signature, raw body, exact callback URL, and optional Upstash
1424
+ region.
1425
+
1426
+ An explicit `url` always wins. Otherwise, Vercel production resolves to
1427
+ `https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler` and preview resolves
1428
+ to `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`. Outside those deployments,
1429
+ a runtime that exposes `process.env` and is not in `NODE_ENV=production`
1430
+ resolves to `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and passes
1431
+ `devMode: true` to the SDK client and receiver. `QSTASH_DEV` is not required.
1432
+ `PORT` must be an integer from 1 through 65535. `QSTASH_DEV_PORT` configures the
1433
+ local QStash server, not the application callback. Automatic startup requires
1434
+ `@upstash/qstash` 2.11.0 or newer in a Node.js runtime. The lazy transport
1435
+ starts it on the first publish or verification. The direct SDK override means
1436
+ `QSTASH_DEV=false` does not disable an inferred local route.
1437
+
1438
+ A non-Vercel production runtime or a process-less runtime requires an explicit
1439
+ absolute URL. Pass one for a custom host or path, an application port not
1440
+ available through `PORT`, or Docker networking. An Edge runtime that exposes
1441
+ `process.env` can infer the local URL, but it must start the QStash development
1442
+ server separately. Outside production, an explicit URL does not override the
1443
+ SDK's development mode, so `QSTASH_DEV` and manually supplied `QSTASH_URL` plus
1444
+ credentials retain their SDK-defined behavior. A2 passes `devMode: false` in
1445
+ production. The inferred Vercel hosts are moving aliases, so pending tasks can
1446
+ reach later deployments on the same production or branch URL.
1447
+
1448
+ Mount the application handler once and pass every server that shares the
1449
+ scheduler:
1450
+
1451
+ ```ts app/api/a2/scheduler/route.ts
1452
+ import { schedulerHandler } from 'experimental-a2/http'
1453
+ import { ordersServer, billingServer } from '@/server'
1454
+
1455
+ export const POST = schedulerHandler(ordersServer, billingServer)
1456
+ ```
1457
+
1458
+ The route must be reachable by the selected QStash server after any platform
1459
+ edge authentication. The development server calls localhost directly. The
1460
+ adapter reads its raw body once and verifies `Upstash-Signature`, the exact URL,
1461
+ and `Upstash-Region` before it parses the delivery or touches a server. Missing
1462
+ or invalid signatures return 401, so ordinary unsigned traffic cannot execute
1463
+ scheduler work. A signed malformed envelope returns 489 with
1464
+ `Upstash-NonRetryable-Error: true`. The same non-retryable response applies
1465
+ when the declared or streamed body exceeds `maxBodyBytes`. Before an initial
1466
+ publish, the adapter checks the normalized task with enough reserved room for
1467
+ its largest possible private store-outage lineage. Append tasks reserve a
1468
+ maximum safe-integer generation. Drain tasks also reserve their actual root
1469
+ `dueAt`. Store-outage successors check their actual body. The adapter therefore
1470
+ cannot initially accept a task that would exceed the same limit solely when
1471
+ lineage is added. Raise the option to match a larger paid-plan payload limit
1472
+ when needed. Keep one value across every deployment that can receive this
1473
+ callback URL. A lower value deployed while older tasks remain can return a
1474
+ non-retryable 489 for a task accepted by the previous value.
1475
+
1476
+ For an inferred Vercel preview callback, the adapter reads the current
1477
+ invocation's `x-vercel-oidc-token` for each publish. When it is a parseable JWT
1478
+ whose expiration remains at least 60 seconds after the task's first due time,
1479
+ the adapter supplies it as `x-vercel-trusted-oidc-idp-token` and requests QStash
1480
+ header redaction. A matching Vercel Trusted Sources rule can then admit the
1481
+ first delivery. QStash receives the credential even though its dashboard and
1482
+ API redact it.
1483
+
1484
+ This is opportunistic, not durable. Missing, malformed, and near-expiry tokens
1485
+ fall back to an ordinary publish, preserving public-preview behavior. An
1486
+ explicit `url` opts out. Inferred production and local callbacks never forward
1487
+ the token. QStash stores the static header and reuses it on retries, so delayed
1488
+ tasks and retries can outlive it. Use an explicit automation bypass or public
1489
+ ingress when every protected delivery must remain authorized. Applications can
1490
+ supply a dedicated bypass through `headers`; A2 requests redaction for its
1491
+ value, but pending messages retain the captured credential after rotation. A
1492
+ delivery-time Vercel relay remains a separate application architecture that
1493
+ requires a Trusted Sources rule, strict target allowlisting, and a versioned
1494
+ routing envelope. QStash signature verification remains mandatory after the
1495
+ edge-access check.
1496
+
1497
+ Settled deliveries return 200. Busy deliveries await a durable successor
1498
+ watchdog before returning 200. Stalled deliveries publish a fresh successor
1499
+ before returning 200, which resets the per-message retry budget. A retryable
1500
+ store outage also hands both drain and append tasks to a fresh successor before
1501
+ acknowledging. Store-outage successors wait 30 seconds, then multiply the delay
1502
+ by four for each signed generation, capped at one day. Their generation keeps
1503
+ increasing after the cap so each handoff has a new deduplication id. Drain
1504
+ lineage includes the first failing task's `dueAt`, contract, and session. Append
1505
+ lineage includes its task id, contract, and session. Duplicate delivery of one
1506
+ generation therefore publishes the same successor key without merging a later
1507
+ drain episode. Append tasks otherwise return 200 after their ordinary fixed-id
1508
+ append succeeds and any recovery watchdog for pending handler work is durable.
1509
+ Unknown contracts, missing scheduler configuration, schema failures, and
1510
+ successor publish failures keep the current delivery retryable. Delivery is at
1511
+ least once, so handler side effects still need idempotency keys.
1512
+
1513
+ The adapter leaves `retries` unset by default so QStash applies its provider
1514
+ default. An explicit non-negative value is passed through. Every delivery
1515
+ attempt is billable. A prolonged outage in which QStash cannot reach the
1516
+ callback still requires DLQ monitoring and redrive because no running callback
1517
+ can publish a successor. QStash's
1518
+ documented 10-minute deduplication window can reduce duplicate watchdogs but is
1519
+ not part of A2's correctness model. Stable scheduled-event ids also make timer
1520
+ redelivery safe after that window. One-shot delays are currently capped at 7
1521
+ days on free plans and 1 year on usage-based plans, with no fixed maximum on
1522
+ fixed plans. Leave at least one second of margin at a plan maximum because
1523
+ `notBefore` uses whole Unix seconds. The adapter carries both recovery watchdogs and
1524
+ `session.schedule` tasks.
1525
+
1526
+ The built-in QStash and Vercel adapters preserve the original provider error
1527
+ from a rejected send. A recognized definite 4xx status or error is terminal
1528
+ except for 408, 409, 425, and 429. Those statuses, 5xx responses, network
1529
+ failures, and errors with no recognized status remain retryable. The Vercel
1530
+ adapter also recognizes the SDK's name-only `BadRequestError`,
1531
+ `UnauthorizedError`, and `ForbiddenError` as terminal. Its native
1532
+ duplicate-message error is success. A2's durable AI tool execution uses this
1533
+ private classification. Direct `session.schedule()` calls still reject with
1534
+ the original error object.
1535
+
1536
+ ## `a2 devtools`
1537
+
1538
+ The package installs an `a2` binary with a read-only `devtools` namespace.
1539
+
1540
+ | Command | Purpose |
1541
+ | --- | --- |
1542
+ | `contracts` | List contracts mounted at the Devtools URL |
1543
+ | `sessions --contract NAME` | List session summaries, with optional `--limit` and `--cursor` |
1544
+ | `show --contract NAME --session ID` | Read one session's exact durable detail |
1545
+ | `export --contract NAME --session ID` | Verify and save an `.a2log`; use `--output` and opt into replacement with `--force` |
1546
+ | `check --contract NAME --session ID` | Verify a live capture and optionally require `--settled`, `--no-dead-letters`, `--no-caught-failures`, or `--max-redispatches N` |
1547
+ | `verify FILE` | Verify a saved capture offline |
1548
+
1549
+ Live commands read the mounted URL from `--url` or `A2_DEVTOOLS_URL`.
1550
+ `A2_DEVTOOLS_TOKEN` supplies the default bearer token. `--bearer-env ENV` and
1551
+ repeatable `--header-env HEADER=ENV` reference other environment variables.
1552
+ Credential values are never accepted as CLI arguments or URL components.
1553
+ Output uses `--format human`, `json`, or `ndjson`.
1554
+
1555
+ ## `experimental-a2/testing`
1556
+
1557
+ Node-only helpers for verified capture loading and pure reducer replay.
1558
+
1559
+ | Export | Contract |
1560
+ | --- | --- |
1561
+ | `loadCapture(source, contract)` | Read a filesystem path or URL, verify its integrity, validate its contract and payloads, and return typed events |
1562
+ | `prepareCapture(capture, contract)` | Apply the same verification and contract typing to an already parsed capture |
1563
+ | `replayCapture(loaded, reducer, { throughIndex? })` | Fold captured events through the reducer, stopping at an optional inclusive index |
1564
+
1565
+ `LoadedCapture` contains the verified `capture`, the `contract`, typed public
1566
+ `events` with `createdAt` revived as `Date`, and unmodified operational
1567
+ `rawEvents`.
1568
+ `CaptureReplay` returns `{ state, index }`. A cutpoint of 0 returns the initial
1569
+ state and index 0. Supporting types are `CaptureSource`, `LoadedCapture`,
1570
+ `ReplayCaptureOptions`, and `CaptureReplay`.
845
1571
 
846
- The built-in memory, SQLite, Postgres, and Redis logs support inspection.
847
- Custom `A2Log` implementations can omit the optional `inspect` interface; the
848
- dashboard returns 501 for those logs.
1572
+ Replay does not use cached snapshot state, construct a server, dispatch a
1573
+ handler, invoke an AI model or tool, or repeat external effects. It reproduces
1574
+ only the current reducer's pure projection over already recorded events.
849
1575
 
850
1576
  ## Entry points
851
1577
 
@@ -855,19 +1581,22 @@ dashboard returns 501 for those logs.
855
1581
  | `experimental-a2/server` | `createServer`: implement a contract | none |
856
1582
  | `experimental-a2/client` | `createClient`: framework-agnostic session client | none |
857
1583
  | `experimental-a2/react` | `createReact` | `react` |
858
- | `experimental-a2/ai` | agent contract, reducer, and append builders: isomorphic | `ai` |
1584
+ | `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
859
1585
  | `experimental-a2/ai/server` | AI SDK runner and built-in handlers | `ai` |
860
- | `experimental-a2/http` | route-side transport helpers | none |
861
- | `experimental-a2/log-postgres` | `postgres`: Postgres log backend | `pg` (or inject a client) |
862
- | `experimental-a2/log-redis` | `redis`: Redis Streams log backend, push-native streaming | `ioredis` (or inject a client) |
863
- | `experimental-a2/log-redis-http` | `redisHttp`: the same Redis log over provider REST APIs (Upstash) | none |
864
- | `experimental-a2/log-sqlite` | SQLite log backend | none |
865
- | `experimental-a2/log-memory` | in-memory log backend | none |
866
- | `experimental-a2/recovery-vercel` | `vercelQueues` recovery | `@vercel/queue` |
1586
+ | `experimental-a2/http` | route-side transport and scheduler helpers | none |
1587
+ | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
1588
+ | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
1589
+ | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
1590
+ | `experimental-a2/store-sqlite` | SQLite store backend | none |
1591
+ | `experimental-a2/store-memory` | in-memory store backend | none |
1592
+ | `experimental-a2/scheduler-vercel` | `vercelQueues`: recovery and timers | `@vercel/queue` |
1593
+ | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
867
1594
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
868
1595
  | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
1596
+ | `experimental-a2/devtools` | versioned wire protocol and exact capture codec | none |
869
1597
  | `experimental-a2/devtools/server` | durable read-only dashboard handler | none |
1598
+ | `experimental-a2/testing` | Node-only capture loading and pure reducer replay | none |
870
1599
 
871
1600
  Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
872
- test in CI, not just convention. Importing `experimental-a2/log-postgres` is what
1601
+ test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
873
1602
  pulls in Postgres, never `experimental-a2` itself, and never your client bundle.