experimental-a2 0.2.0 → 0.4.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 (92) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/dist/ai-B4YhEnfw.d.ts +333 -0
  3. package/dist/ai-server.d.ts +48 -9
  4. package/dist/ai-server.js +121 -49
  5. package/dist/ai.d.ts +2 -303
  6. package/dist/ai.js +231 -86
  7. package/dist/cli-B3VuxoDe.js +597 -0
  8. package/dist/cli-bin.d.ts +1 -0
  9. package/dist/cli-bin.js +5 -0
  10. package/dist/cli.d.ts +19 -0
  11. package/dist/cli.js +2 -0
  12. package/dist/client-BrfDXQ8A.d.ts +155 -0
  13. package/dist/client-Bt4tAKi9.js +798 -0
  14. package/dist/client.d.ts +2 -90
  15. package/dist/client.js +1 -409
  16. package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
  17. package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
  18. package/dist/devtools-J_jZ2vQf.d.ts +151 -0
  19. package/dist/devtools-kJJaORn-.js +338 -0
  20. package/dist/devtools-server.browser.js +1 -1
  21. package/dist/devtools-server.d.ts +2 -2
  22. package/dist/devtools-server.js +224 -43
  23. package/dist/devtools.d.ts +2 -0
  24. package/dist/devtools.js +2 -0
  25. package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
  26. package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
  27. package/dist/http.d.ts +71 -13
  28. package/dist/http.js +302 -41
  29. package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +2 -2
  32. package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
  33. package/dist/internal-aEotMzu_.js +209 -0
  34. package/dist/otel.d.ts +1 -1
  35. package/dist/platform-B4TnJtWu.js +32 -0
  36. package/dist/react.d.ts +37 -14
  37. package/dist/react.js +26 -15
  38. package/dist/scheduler-qstash.d.ts +78 -0
  39. package/dist/scheduler-qstash.js +499 -0
  40. package/dist/scheduler-task-BpzhPnRS.js +54 -0
  41. package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
  42. package/dist/scheduler-vercel.js +226 -0
  43. package/dist/server-CcNnFnoW.js +1405 -0
  44. package/dist/server-YtPq7hjw.d.ts +260 -0
  45. package/dist/server.d.ts +4 -155
  46. package/dist/server.js +2 -2
  47. package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
  48. package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
  49. package/dist/store-codec-DTG0Ftek.js +8 -0
  50. package/dist/store-memory.d.ts +11 -0
  51. package/dist/{log-memory.js → store-memory.js} +127 -24
  52. package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
  53. package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
  54. package/dist/{log-postgres.js → store-postgres.js} +158 -24
  55. package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
  56. package/dist/store-redis-http.d.ts +21 -0
  57. package/dist/store-redis-http.js +70 -0
  58. package/dist/store-redis.d.ts +37 -0
  59. package/dist/store-redis.js +298 -0
  60. package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
  61. package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
  62. package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
  63. package/dist/testing.browser.d.ts +1 -0
  64. package/dist/testing.browser.js +4 -0
  65. package/dist/testing.d.ts +31 -0
  66. package/dist/testing.js +101 -0
  67. package/dist/wire-DCUZBUlT.js +222 -0
  68. package/docs/01-quickstart.mdx +4 -5
  69. package/docs/concepts/01-contracts.mdx +21 -17
  70. package/docs/concepts/02-handlers.mdx +7 -7
  71. package/docs/concepts/03-durability.mdx +26 -29
  72. package/docs/concepts/04-state.mdx +18 -21
  73. package/docs/guides/01-timers.mdx +154 -54
  74. package/docs/guides/02-cancellation.mdx +30 -4
  75. package/docs/guides/03-react.mdx +20 -21
  76. package/docs/guides/04-local-first.mdx +1 -1
  77. package/docs/guides/05-production.mdx +321 -60
  78. package/docs/guides/06-ai-agents.mdx +249 -49
  79. package/docs/guides/07-devtools.mdx +137 -12
  80. package/docs/guides/08-application-data.mdx +1 -1
  81. package/docs/guides/09-presence.mdx +284 -0
  82. package/docs/guides/10-transports.mdx +131 -0
  83. package/docs/index.mdx +22 -46
  84. package/docs/reference/01-api.mdx +751 -102
  85. package/docs/reference/02-errors.mdx +10 -5
  86. package/package.json +23 -6
  87. package/dist/internal-D6wNxTck.js +0 -36
  88. package/dist/log-memory.d.ts +0 -11
  89. package/dist/log-redis.d.ts +0 -31
  90. package/dist/recovery-vercel.js +0 -119
  91. package/dist/server-DJgD2YWP.js +0 -877
  92. 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 came from `parsePushBody()`,
127
+ 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. `parsePushBody()` 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)`
@@ -354,7 +435,7 @@ identical batch returns the original rows. (Events parsed by
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,21 +520,89 @@ 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>
407
536
  ```
408
537
 
409
- A live feed of the session's events, starting after `startAt`.
538
+ A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
+ `startAfter: 20` begins with event 21.
410
540
  Server-side only; expose it over SSE with `sseResponse`. Subscribing never
411
541
  dispatches handlers.
412
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. `sseResponse` sends both as named SSE frames, so clients that
547
+ 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>
564
+ ```
565
+
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` object `parsePushBody` returns,
584
+ so a push route forwards it whole: `session.setPresence(presence)`. The
585
+ 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.
605
+
413
606
  ## Event
414
607
 
415
608
  ```ts
@@ -456,23 +649,54 @@ 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, connection, presence, setPresence } =
670
+ 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 observed feed. Unless `initialEvents`
675
+ explicitly seeds earlier entries, it begins after `initialIndex`. `index` is
676
+ the stream frontier, the `lastSeenIndex` for
677
+ [cancellation](/guides/cancellation).
678
+
679
+ `presence` is the replicated ephemeral map,
680
+ `Record<participantId, { [field]: { value, seen, at } }>`, including
681
+ this client. `setPresence(values)` is fire-and-forget: validated
682
+ locally, merged into the local map immediately, coalesced on the wire
683
+ to a fixed cadence (a leading send immediately, then at most one
684
+ merged send per interval; later values win per field), stamped with
685
+ the frontier at send time as `seen`. Parallel sends are safe: merges
686
+ are last-writer-wins by `at`, and the client's stamps are monotonic
687
+ per session (wall-seeded, guarded so overlapping sends can never
688
+ invert). No ack, no `confirmed`, no retry.
689
+
690
+ Both members exist only when the contract declares `presence`; their
691
+ value and field types come from its schemas, through the reducer, with
692
+ no type arguments (the reducer is the client's typed handle on the
693
+ contract; it still never folds presence). `setPresence` requires the
694
+ provider's `participant`. If the map holds only your own echo with
695
+ participants active, the GET route forgot `presence: true`. See
696
+ [Presence](/guides/presence).
473
697
 
474
698
  `push` appends optimistically: validated locally, rolled back on
475
- rejection, retried only for `LOG_UNAVAILABLE`. Awaiting it gives the
699
+ rejection, retried only for `STORE_UNAVAILABLE`. Awaiting it gives the
476
700
  server ack; the same result carries `confirmed`, a lazy promise for
477
701
  the later moment when the live stream has delivered the batch back and
478
702
  the view shows server truth:
@@ -485,6 +709,12 @@ await result.confirmed // the stream round-trip: ack→stream latency
485
709
  const roundtripMs = performance.now() - t0
486
710
  ```
487
711
 
712
+ Overlapping calls through the same session client update the view
713
+ immediately, then enter the transport in invocation order. Each call waits
714
+ only for the previous call's ack or final rejection, not for `confirmed`.
715
+ Other session IDs and clients remain concurrent; they converge on the log's
716
+ durable order.
717
+
488
718
  A rejected push rejects both promises with the same `A2Error`;
489
719
  `confirmed` is materialized only when accessed, so ignoring it costs
490
720
  nothing. It also reads as intent: `await push(...).confirmed` is
@@ -527,14 +757,47 @@ definition exposes only `contract` and `reducer`.
527
757
 
528
758
  Built-in event names cannot be replaced by `options.events`.
529
759
 
760
+ ### `handlerContext(agent)`
761
+
762
+ Reads the durable A2 handler context ambient to the current tool
763
+ execution. Tools are plain AI SDK `tool()` definitions; A2 passes their
764
+ options through to the AI SDK without interpreting them and runs each
765
+ local `execute` inside an ambient context scope. Call
766
+ `handlerContext(agent)` (or pass a contract) anywhere below `execute`,
767
+ including awaited helpers and async iteration. The argument types the
768
+ result and is verified against the executing server's contract: a
769
+ mismatched agent, or a call outside any tool execution, throws a
770
+ `TypeError`.
771
+
772
+ The returned `AgentToolContext<D>` is a union of the current A2
773
+ handler's `event`, `attempt`, `session`, and `signal`. Automatic
774
+ execution has an `ai.tool.called` event; execution after approval has an
775
+ `ai.approval.responded` event. Narrow `ctx.event.type` when
776
+ event-specific payload fields matter. Both variants have a session typed
777
+ from the supplied agent's complete event vocabulary. The agent does not
778
+ hold a scheduler. Calls such as `ctx.session.schedule()` use the
779
+ scheduler of the server executing the tool.
780
+
781
+ The context is runtime capability, not event data. A2 creates it for the
782
+ current attempt, never persists it, and never sends it to the model or a
783
+ provider-executed tool. Each retry reads that attempt's fresh context.
784
+ Model-phase tool options (`contextSchema`, dynamic descriptions,
785
+ `needsApproval` predicates, input lifecycle callbacks) are the AI SDK's
786
+ own; A2 forwards them untouched, and durable execution passes no AI SDK
787
+ `context` option. `needsApproval` and `generation.toolApproval`
788
+ predicates run during the model step and are re-evaluated on recovery
789
+ for the same durable tool call, so keep them deterministic over the tool
790
+ input. Model-phase hooks see tool definitions without local `execute`
791
+ functions.
792
+
530
793
  ### `inputs`
531
794
 
532
795
  Pure typed inputs for `append()` and `push()`:
533
796
 
534
797
  | Input | Events |
535
798
  | --- | --- |
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 |
799
+ | `inputs.message(message, { generate? })` | `ai.message.created`; pass `generate: false` to record context without scheduling |
800
+ | `inputs.seed(message)` | `ai.message.created` with `generate: false`; non-user roles require a trusted append |
538
801
  | `inputs.approval(response)` | `ai.approval.responded` only |
539
802
  | `inputs.input(response)` | `ai.input.responded` only |
540
803
  | `inputs.requestInput(request)` | `ai.input.requested` for a trusted server append |
@@ -547,8 +810,20 @@ when an application uses them. Input event ids are stable for the interaction
547
810
  they describe, so a lost append acknowledgment can be resent safely. Browser
548
811
  ingress accepts user messages, approval and input responses, interruptions,
549
812
  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.
813
+ and AI lifecycle events. `inputs.requestInput()` and non-user messages built
814
+ with `inputs.seed()` are for trusted server appends.
815
+
816
+ `inputs.message(message, { generate: false })` is valid browser input. It keeps
817
+ the user message in `AIState.messages` without starting a model turn. The next
818
+ user message that allows generation includes passive user messages before it
819
+ in model context. Passive user messages after that trigger wait for a later
820
+ generating message. Omitting the option preserves the default scheduling
821
+ behavior and the existing `{ message }` event payload. `inputs.seed()` writes
822
+ the same passive flag. Trusted assistant and system seeds are context, not user
823
+ queue cutpoints. The server never schedules them, regardless of the `generate`
824
+ field. Model output is generation progress rather than another
825
+ `ai.message.created` fact, so an assistant response cannot recursively start a
826
+ new turn.
552
827
 
553
828
  Approval and input request/response payloads require the active
554
829
  `generationId`. Clients copy it from the pending request, which prevents a
@@ -561,10 +836,22 @@ delayed response from satisfying a newer model step.
561
836
  `ai.generation.failed` sets `stepLimit: true` when `maxSteps` rejects a
562
837
  continuation before another model step starts.
563
838
 
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.
839
+ `ai.message.interrupted` carries either `{ messageId, requestId, reason?,
840
+ lastSeenIndex? }` or `{ messageId, generationId, reason?, lastSeenIndex? }`.
841
+ Exactly one owner ID is required. Clients copy `requestId` from
842
+ `activeRequestId` before generation start and switch to the observed
843
+ `activeGeneration.generationId` after start. A request-owned interruption
844
+ remains valid if that same request starts before the append commits. A delayed
845
+ interruption for an older owner is a projection and cancellation no-op.
846
+ The message and owner IDs are validated as one pair. If either ID does not
847
+ identify that same response owner, the interruption is stale and is a no-op.
848
+
849
+ An accepted interruption terminally fences its request or generation. Later
850
+ generation, tool, approval, input, and compaction events remain in raw history
851
+ but cannot alter `AIState`, satisfy pending work, or reactivate the response.
852
+ Completed tool results at or before `lastSeenIndex` remain completed. A final
853
+ tool result that races an ordinary generation failure remains authoritative in
854
+ either commit order, while the generation stays failed.
568
855
 
569
856
  ### `events` and `createEvents(options?)`
570
857
 
@@ -581,15 +868,24 @@ createReducer({ contract, name? }): Reducer<AIState>
581
868
  Builds the standard AI projection for a compatible contract. `AIState`
582
869
  contains session lifecycle, messages, generation status, pending approvals
583
870
  and input, tool activity, compaction, usage, the last error, and
584
- `activeRequestId` and `activeResponseMessageId`, plus
585
- `responseGenerationIds: Record<string, string>`.
871
+ `activeRequestId`, `activeResponseMessageId`,
872
+ `responseGenerationIds: Record<string, string>`, and terminal request and
873
+ generation ownership fences.
586
874
  `activeRequestId` is the server-authorized generation request and fences
587
875
  delayed requests before their generation starts. `activeResponseMessageId`
588
876
  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.
877
+ `activeProjection` holds the indexed generation frontier while a generation is
878
+ active, after a generation step completes while its response waits on tool,
879
+ approval, or input barriers, and after a generation fails while it awaits retry
880
+ or interruption. Response completion, interruption, supersession, retry, or a
881
+ later generation clears or replaces it. `responseGenerationIds` keeps the
882
+ latest generation owner for each response message, so late events from a
883
+ superseded owner cannot alter the projection. `terminalRequestIds` and
884
+ `terminalGenerations` preserve accepted completion, failure, interruption,
885
+ and supersession fences across snapshots and recovery. They are optional
886
+ snapshot-compatible fields with the shapes `Record<string, true>` and
887
+ `Record<string, 'completed' | 'failed' | 'interrupted' | 'superseded'>`.
888
+ Extension events are ignored. The default reducer name is `a2.ai.state.v8`.
593
889
 
594
890
  ### `deriveUIMessages(history)` and `reduceAIState(state, event)`
595
891
 
@@ -609,10 +905,11 @@ createAgentServer({
609
905
  instructions?,
610
906
  generation?,
611
907
  maxSteps?,
908
+ messageMetadata?,
612
909
  generate?,
613
910
  compaction?,
614
911
  progress?,
615
- log?, recovery?, telemetry?, handlers?,
912
+ store?, scheduler?, telemetry?, handlers?,
616
913
  }): A2Server
617
914
  ```
618
915
 
@@ -626,12 +923,23 @@ async resolvers.
626
923
 
627
924
  `generation` contains per-step settings such as `temperature`,
628
925
  `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.
926
+ the one-step stop condition, local tool execution, and continuation. Responses
927
+ have no step limit by default. Set `maxSteps` to a positive integer to limit one
928
+ complete assistant response. Support for individual model settings depends on
929
+ the selected model and provider. `generation` excludes `stopWhen`, tool
930
+ execution callbacks, tool callers, sandbox execution, and the tool approval
931
+ secret. Everything else, including `toolsContext`, passes through to the AI SDK
932
+ untouched. A local tool reads its durable context with `handlerContext()` when
933
+ its handler executes. Model-step timeouts remain available; tool-execution
934
+ timeouts are not.
935
+
936
+ `messageMetadata(context)` synchronously maps each default AI SDK stream part
937
+ to the metadata type of the agent's `UIMessage`. The context includes the
938
+ generation state and the current `TextStreamPart`. Metadata returned for
939
+ standard `start` and `finish` parts is recorded with generation progress and
940
+ shallow-merged onto the projected assistant message. Use the `finish` part's
941
+ `totalUsage` to retain final token usage. A custom `generate` function owns its
942
+ metadata chunks and does not use this mapper.
635
943
 
636
944
  `generate(context)` optionally replaces the default AI SDK generation. It
637
945
  receives messages, the resolved model and instructions, tools, generation
@@ -675,6 +983,13 @@ When selected, both the request and the replacement messages enter the log.
675
983
 
676
984
  This entry point is server-only and resolves to a throwing browser stub.
677
985
 
986
+ ### `runWithHandlerContext(ctx, fn)`
987
+
988
+ The testing seam for context-reading tools: runs `fn` with `ctx`
989
+ installed as the ambient handler context, so a tool's `execute` can be
990
+ called directly in tests. Inside `fn`, `handlerContext()` returns `ctx`
991
+ for any agent argument.
992
+
678
993
  ### `createHandlers(options)`
679
994
 
680
995
  Returns the built-in A2 handler table without constructing a server. Spread
@@ -691,7 +1006,7 @@ handler. Custom assemblies pass `validateAgentPush` as
691
1006
 
692
1007
  Accepts the browser interaction allowlist: user messages, approval and input
693
1008
  responses, interruptions, and explicit retries. It rejects server-authored
694
- scheduling and lifecycle events, trusted seed messages, and input requests.
1009
+ scheduling and lifecycle events, seeded non-user messages, and input requests.
695
1010
  `createAgentServer()` installs it automatically.
696
1011
 
697
1012
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
@@ -702,38 +1017,60 @@ examples.
702
1017
  ```ts
703
1018
  createClient(options: {
704
1019
  reducer: Reducer
705
- api: string
1020
+ api: ClientApi
706
1021
  gcTime?: number // idle session lifetime; 5 minutes by default
707
1022
  }): A2Client
1023
+
1024
+ type ClientApi =
1025
+ | string // one route: GET SSE stream + POST push
1026
+ | { type: 'http'; push: string; stream: string } // split routes
1027
+ | { type: 'ws'; url: string } // one socket, both directions
708
1028
  ```
709
1029
 
710
1030
  The framework-agnostic session client `experimental-a2/react` is built on: the SSE
711
1031
  subscription with frontier resume and reconnection, the optimistic push
712
1032
  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.
1033
+ initialState?, initialIndex?, initialEvents?, participant? })` returns a
1034
+ handle with `getSnapshot()`/`subscribe()` (the `useSyncExternalStore`
1035
+ contract), `push()`, `connect()`, and `close()`. Snapshots carry
1036
+ `state`, `events`, `index`, and `connection` (the same fields
1037
+ `useSession` exposes), and `push` returns the same ack-then-`confirmed`
1038
+ result. On contracts that declare `presence` the handle also carries
1039
+ `setPresence()` and snapshots carry the `presence` map, exactly like
1040
+ the hook; `participant` is the identity `setPresence` sends under. Use
1041
+ it directly from any other framework, or none.
719
1042
 
720
1043
  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.
1044
+ handle. A later call with a further server-rendered frontier advances the
1045
+ handle and does not drop pending optimistic events; a stale render cannot move
1046
+ it backward. Optional `initialEvents` seed earlier raw events for history UIs
1047
+ and merge into that feed. They do not affect the folded state or stream
1048
+ frontier. Idle handles are evicted after `gcTime`. This memory layer is
1049
+ separate from `experimental-a2/cache-indexeddb`: memory preserves identity
1050
+ across route transitions, while IndexedDB preserves the replica across reloads.
727
1051
 
728
1052
  ## `experimental-a2/http`
729
1053
 
730
1054
  | Helper | What it does |
731
1055
  | ----------------------- | ------------------------------------------------------------------------------ |
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 |
1056
+ | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1057
+ | `parsePushBody(req)` | validates the push envelope `{ sessionId, events, presence? }`, throws `INVALID_PAYLOAD` |
1058
+ | `sseResponse(iterable)` | pipes a `session.stream()` iterable into an SSE `Response`, with a `: connected` prelude, a `: ping` heartbeat every 15s, and a clean close one second before an ambient Vercel invocation deadline when available; presence patches ride as named `presence` frames |
1059
+ | `sessionSocket(session, socket, options?)` | speaks the A2 wire over any `ws`-shaped socket: the stream pumps down as JSON frames, pushes and presence come up with the same validation and `validatePush` seam as the POST route; `options` carries `startAfter`, `presence`, and an optional `deadline` for clean pre-deadline closes. See [Transports](/guides/transports) |
734
1060
  | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status |
735
1061
  | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
736
1062
 
1063
+ `schedulerHandler(...servers)` is the application-facing scheduler route. It
1064
+ requires at least one A2 server. Every server must have a scheduler, use the
1065
+ exact same scheduler instance, and have a unique contract name. Non-A2 values
1066
+ also fail. These checks throw before the request handler is returned, so bad
1067
+ wiring fails when the route module loads. The helper then delegates delivery
1068
+ to the shared adapter's `A2Scheduler.handler(...)` method.
1069
+
1070
+ Different scheduler instances use different routes. Match each QStash route to
1071
+ that instance's resolved `url`; additional QStash routes pass an explicit
1072
+ `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1073
+
737
1074
  Together the last two are the `A2Error` wire format that `push` and the
738
1075
  push route share. See [Errors](/reference/errors#over-the-wire).
739
1076
 
@@ -767,7 +1104,7 @@ Four spans, all carrying `a2.contract` and `a2.session_id`:
767
1104
 
768
1105
  | Span | Wraps |
769
1106
  | ----------- | ------------------------------------------------------ |
770
- | `a2.append` | validation + the log write (one batch) |
1107
+ | `a2.append` | validation + the store write (one batch) |
771
1108
  | `a2.drain` | one drain pass over a session's backlog |
772
1109
  | `a2.event` | one claimed dispatch of one event |
773
1110
  | `a2.state` | one `state()` read: snapshot-plus-tail load + fold |
@@ -783,7 +1120,7 @@ alert on are all mid-span.
783
1120
  | `a2.append.mode` | `a2.append` | start | `inline` \| `dispatch` |
784
1121
  | `a2.append.types` | `a2.append` | start | comma-joined event types |
785
1122
  | `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 |
1123
+ | `a2.append.armed` | `a2.append` | mid | `false` when the scheduler arm failed and this append degraded to append-driven healing |
787
1124
  | `a2.drain.outcome` | `a2.drain` | mid | `settled` \| `busy` \| `stalled` |
788
1125
  | `a2.drain.processed` | `a2.drain` | mid | events processed this pass |
789
1126
  | `a2.event.type` | `a2.event` | start | the event's type |
@@ -792,7 +1129,7 @@ alert on are all mid-span.
792
1129
  | `a2.event.attempt` | `a2.event` | start | same durable 1-based ordinal as `ctx.attempt` |
793
1130
  | `a2.event.lane` | `a2.event` | start | stored lane value; absent for concurrent unlaned work |
794
1131
  | `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` |
1132
+ | `a2.event.outcome` | `a2.event` | mid | `processed` \| `failed` \| `dead_lettered` \| `superseded` \| `surrendered` |
796
1133
  | `a2.event.aborted` | `a2.event` | mid | `true` when `abortOn` fired during the run |
797
1134
  | `a2.state.reducer` | `a2.state` | start | the reducer's name |
798
1135
  | `a2.state.snapshot` | `a2.state` | mid | `hit` \| `miss` \| `rejected` (schema guard discarded it) |
@@ -814,6 +1151,54 @@ log.
814
1151
  renames and additions are breaking for dashboards, and are called out in the
815
1152
  package's `CHANGELOG.md`.
816
1153
 
1154
+ ## `experimental-a2/devtools`
1155
+
1156
+ The shared, isomorphic protocol for browser Devtools, the CLI, and `.a2log`
1157
+ captures.
1158
+
1159
+ | Export | Contract |
1160
+ | --- | --- |
1161
+ | `DEVTOOLS_PROTOCOL_VERSION` | Current HTTP wire protocol version |
1162
+ | `DEVTOOLS_CAPTURE_VERSION` | Current `.a2log` record format version |
1163
+ | `DEVTOOLS_CAPTURE_MEDIA_TYPE` | `application/x-ndjson` |
1164
+ | `DEVTOOLS_SESSION_PAGE_LIMIT` | Default event count requested per session page |
1165
+ | `DEVTOOLS_SESSION_PAGE_MAX_LIMIT` | Maximum event count accepted per session page |
1166
+ | `DEVTOOLS_CAPABILITIES` | Protocol resources and capture format advertised by the server |
1167
+ | `devtoolsSessionRevision({ events, snapshots })` | Compute the lifecycle invalidation token after assembling pages |
1168
+ | `encodeDevtoolsCapture(detail, { capturedAt? })` | Encode one session detail as exact NDJSON and add its SHA-256 footer |
1169
+ | `parseDevtoolsCapture(value)` | Parse NDJSON text or bytes into a `DevtoolsCapture` while retaining unknown fields on known records |
1170
+ | `verifyDevtoolsCapture(capture)` | Verify versions, record shapes, session identity, indexes, counts, dates, and the SHA-256 digest |
1171
+
1172
+ The wire types are `DevtoolsWireSessionSummary`, `DevtoolsWireEvent`,
1173
+ `DevtoolsWireSnapshot`, `DevtoolsContractsResponse`,
1174
+ `DevtoolsSessionsResponse`, `DevtoolsSessionDetail`, `DevtoolsSessionPage`, and
1175
+ `DevtoolsCapabilities`. Capture records use `DevtoolsCaptureManifest`,
1176
+ `DevtoolsCaptureEvent`, `DevtoolsCaptureSnapshot`,
1177
+ `DevtoolsCaptureFooter`, and `DevtoolsCaptureRecord`. A parsed
1178
+ `DevtoolsCapture` exposes the same canonical sequence through `records`.
1179
+
1180
+ ```ts test/verify-a2log.ts
1181
+ import { readFile } from 'node:fs/promises'
1182
+ import {
1183
+ parseDevtoolsCapture,
1184
+ verifyDevtoolsCapture,
1185
+ } from 'experimental-a2/devtools'
1186
+
1187
+ export async function verifyA2Log(path: string) {
1188
+ const capture = parseDevtoolsCapture(await readFile(path))
1189
+ await verifyDevtoolsCapture(capture)
1190
+ return capture
1191
+ }
1192
+ ```
1193
+
1194
+ The manifest records the contract, session, revision, capture time, protocol
1195
+ version, and capture version. Event records contain the exact durable payload
1196
+ and operational bookkeeping. Snapshot records contain metadata but never
1197
+ cached reducer state. The final `end` record contains counts, the highest event
1198
+ index, and a digest of every preceding encoded record. The session revision is
1199
+ an invalidation token, not the integrity digest. Encoding rejects durable
1200
+ values that canonical JSON would coerce or omit.
1201
+
817
1202
  ## `experimental-a2/devtools/server`
818
1203
 
819
1204
  ```ts
@@ -838,14 +1223,274 @@ from stored events. The initial page response includes the selected dashboard
838
1223
  data so the browser does not need a contracts, sessions, and detail request
839
1224
  waterfall. It never drains or heals a session.
840
1225
 
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.
1226
+ Every route is a `GET`. The versioned resources are `capabilities`,
1227
+ `contracts`, `sessions`, `session`, `watch`, and `export` under the handler's
1228
+ `_a2/` path. `export` returns an integrity-checkable `.a2log` with the media
1229
+ type from `DEVTOOLS_CAPTURE_MEDIA_TYPE`.
1230
+
1231
+ `session` without pagination parameters returns one complete detail. Add a
1232
+ positive `limit` to receive a `DevtoolsSessionPage`. Its `throughIndex` is the
1233
+ inclusive event frontier frozen by the first request. Pass its opaque `cursor`
1234
+ back until the cursor is null. Snapshot metadata appears on the first page
1235
+ only. The cursor is bound to its contract and session, and the server rejects
1236
+ reuse against another log. The advertised maximum page size is clamped by the
1237
+ server.
1238
+
1239
+ The browser, CLI, watch loop, and export route walk these bounded pages for
1240
+ you. They reject a gap or early end. An exact export includes every event
1241
+ through one finite frontier, even when a backend provider limits one range
1242
+ response. Events appended during the walk belong to a later read.
1243
+
1244
+ Without `authorize`, the handler is available only when `NODE_ENV` is exactly
1245
+ `development`. Any other value, including unset, returns 404. When `authorize`
1246
+ is present, only a literal `true` grants access. Returning `false` also returns
1247
+ 404. Returning a `Response` passes that response through, which supports
1248
+ redirects and authentication challenges. The mounted handler is the
1249
+ authorization boundary for the browser, CLI, SSE stream, and exact capture
1250
+ download. A2 does not add a separate Devtools credential store.
1251
+
1252
+ The built-in memory, SQLite, Postgres, and Redis stores support inspection.
1253
+ Custom `A2Store` implementations can omit the optional `inspect` interface; the
1254
+ dashboard returns 501 for those logs. A custom inspection implementation may
1255
+ add `readEvents(sessionId, { afterIndex, throughIndex?, limit })` for efficient
1256
+ bounded reads. It returns `{ events, throughIndex }`, where the first call
1257
+ freezes the inclusive frontier and continuations preserve it. Without this
1258
+ method, A2 reads the complete log for each page and slices it in memory.
1259
+
1260
+ ## `experimental-a2/scheduler-vercel`
1261
+
1262
+ `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
1263
+
1264
+ ```ts server/scheduler-vercel.ts
1265
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
1266
+
1267
+ export const scheduler = vercelQueues()
1268
+ ```
1269
+
1270
+ | Option | Type | Default |
1271
+ | --- | --- | --- |
1272
+ | `topic` | `string` matching `[A-Za-z0-9_-]+` | `'a2'` |
1273
+ | `scheduledAppends` | `'deployment' \| 'deploymentless'` | `'deployment'` |
1274
+ | `transport` | `QueueTransport` | lazy `@vercel/queue` client |
1275
+ | `clock` | `{ now(): Date }` | system clock |
1276
+
1277
+ The default pins recovery watchdogs and scheduled appends to the deployment
1278
+ that publishes them. Recovery always remains pinned. Set `scheduledAppends` to
1279
+ `'deploymentless'` only when every eligible deploymentless consumer for the
1280
+ topic can decode pending tasks and validate their events. This option removes
1281
+ deployment affinity. It does not select the latest production deployment.
1282
+
1283
+ The adapter retains every task for seven days and accepts delays through six
1284
+ days, leaving the final day for delivery and retries. `transport` and `clock`
1285
+ are advanced injection seams for tests and custom delivery. An injected
1286
+ transport receives the selected deployment affinity as an optional fourth send
1287
+ argument while its message remains an exact `SchedulerTask`. It must honor that
1288
+ hint for `scheduledAppends: 'deploymentless'` to affect its provider-specific
1289
+ routing. The built-in transport adds its callback-routing metadata only after
1290
+ this injection seam.
1291
+
1292
+ ## `experimental-a2/scheduler-qstash`
1293
+
1294
+ `qstash(options?)` returns an `A2Scheduler` backed by QStash HTTP delivery.
1295
+ Vercel Queues remains the recommended default on Vercel. Use QStash when you
1296
+ want a scheduler that calls a stable public endpoint.
1297
+
1298
+ ```ts server/scheduler-qstash.ts
1299
+ import { qstash } from 'experimental-a2/scheduler-qstash'
1300
+
1301
+ export const scheduler = qstash()
1302
+ ```
1303
+
1304
+ | Option | Type | Default |
1305
+ | --- | --- | --- |
1306
+ | `url` | `string` | conventional local or Vercel callback; required for process-less runtimes, other production, and custom routes |
1307
+ | `token` | `string` | `QSTASH_TOKEN` |
1308
+ | `currentSigningKey` | `string` | `QSTASH_CURRENT_SIGNING_KEY` |
1309
+ | `nextSigningKey` | `string` | `QSTASH_NEXT_SIGNING_KEY` |
1310
+ | `headers` | `Readonly<Record<string, string>>` | no additional callback headers |
1311
+ | `client` | `QStashPublishingClient` | lazy `@upstash/qstash` client |
1312
+ | `retries` | non-negative integer | the QStash provider default |
1313
+ | `maxBodyBytes` | positive integer | `1_048_576` (1 MiB) for callbacks and reserved outbound bodies |
1314
+ | `transport` | `QStashTransport` | lazy `@upstash/qstash` client and receiver |
1315
+ | `clock` | `{ now(): Date }` | system clock |
1316
+
1317
+ `headers` adds static headers to every callback delivery, including recovery
1318
+ successors. A2 validates and snapshots the record when `qstash()` runs, then
1319
+ asks QStash to redact every configured value from its dashboard and API. QStash
1320
+ still receives, stores, and delivers the original values. Header names are
1321
+ case-insensitive. Provider-owned `Upstash-*` headers, `Content-Type*`, HTTP
1322
+ framing headers, and A2's managed Vercel identity headers are reserved.
1323
+
1324
+ `client` accepts the structural `publishJSON(...)` part of a configured
1325
+ `@upstash/qstash` `Client`. A2 still creates the `Receiver` lazily from the
1326
+ signing-key options. `client` cannot be combined with `token` or `transport`.
1327
+ Use it to configure the QStash API endpoint, publish-request retry policy, or
1328
+ telemetry. Use A2's `headers` option for callback credentials. Client-global
1329
+ callback headers and redaction are outside A2's merge contract. The injected
1330
+ client receives raw callback credentials, including an automatic preview
1331
+ identity, and must honor the per-publish redaction request.
1332
+
1333
+ `transport` and `clock` are advanced injection seams for tests and custom
1334
+ delivery. A `QStashTransport` implements `publish(request)` and
1335
+ `verify(request)`. Publish requests contain the callback `url`, a complete
1336
+ `SchedulerTask` body, optional callback `headers`, optional `redact.header`
1337
+ names, optional Unix-seconds `notBefore`, and a deterministic
1338
+ `deduplicationId`. Store-outage successors add provider-private generation
1339
+ metadata to that body. Custom transports must preserve the body, deliver every
1340
+ header, and apply every redaction request. An explicit `retries` value is
1341
+ forwarded; omission lets QStash use its provider default. Verify requests
1342
+ contain the signature, raw body, exact callback URL, and optional Upstash
1343
+ region.
1344
+
1345
+ An explicit `url` always wins. Otherwise, Vercel production resolves to
1346
+ `https://${VERCEL_PROJECT_PRODUCTION_URL}/api/a2/scheduler` and preview resolves
1347
+ to `https://${VERCEL_BRANCH_URL}/api/a2/scheduler`. Outside those deployments,
1348
+ a runtime that exposes `process.env` and is not in `NODE_ENV=production`
1349
+ resolves to `http://localhost:${PORT ?? 3000}/api/a2/scheduler` and passes
1350
+ `devMode: true` to the SDK client and receiver. `QSTASH_DEV` is not required.
1351
+ `PORT` must be an integer from 1 through 65535. `QSTASH_DEV_PORT` configures the
1352
+ local QStash server, not the application callback. Automatic startup requires
1353
+ `@upstash/qstash` 2.11.0 or newer in a Node.js runtime. The lazy transport
1354
+ starts it on the first publish or verification. The direct SDK override means
1355
+ `QSTASH_DEV=false` does not disable an inferred local route.
1356
+
1357
+ A non-Vercel production runtime or a process-less runtime requires an explicit
1358
+ absolute URL. Pass one for a custom host or path, an application port not
1359
+ available through `PORT`, or Docker networking. An Edge runtime that exposes
1360
+ `process.env` can infer the local URL, but it must start the QStash development
1361
+ server separately. Outside production, an explicit URL does not override the
1362
+ SDK's development mode, so `QSTASH_DEV` and manually supplied `QSTASH_URL` plus
1363
+ credentials retain their SDK-defined behavior. A2 passes `devMode: false` in
1364
+ production. The inferred Vercel hosts are moving aliases, so pending tasks can
1365
+ reach later deployments on the same production or branch URL.
1366
+
1367
+ Mount the application handler once and pass every server that shares the
1368
+ scheduler:
1369
+
1370
+ ```ts app/api/a2/scheduler/route.ts
1371
+ import { schedulerHandler } from 'experimental-a2/http'
1372
+ import { ordersServer, billingServer } from '@/server'
1373
+
1374
+ export const POST = schedulerHandler(ordersServer, billingServer)
1375
+ ```
1376
+
1377
+ The route must be reachable by the selected QStash server after any platform
1378
+ edge authentication. The development server calls localhost directly. The
1379
+ adapter reads its raw body once and verifies `Upstash-Signature`, the exact URL,
1380
+ and `Upstash-Region` before it parses the delivery or touches a server. Missing
1381
+ or invalid signatures return 401, so ordinary unsigned traffic cannot execute
1382
+ scheduler work. A signed malformed envelope returns 489 with
1383
+ `Upstash-NonRetryable-Error: true`. The same non-retryable response applies
1384
+ when the declared or streamed body exceeds `maxBodyBytes`. Before an initial
1385
+ publish, the adapter checks the normalized task with enough reserved room for
1386
+ its largest possible private store-outage lineage. Append tasks reserve a
1387
+ maximum safe-integer generation. Drain tasks also reserve their actual root
1388
+ `dueAt`. Store-outage successors check their actual body. The adapter therefore
1389
+ cannot initially accept a task that would exceed the same limit solely when
1390
+ lineage is added. Raise the option to match a larger paid-plan payload limit
1391
+ when needed. Keep one value across every deployment that can receive this
1392
+ callback URL. A lower value deployed while older tasks remain can return a
1393
+ non-retryable 489 for a task accepted by the previous value.
1394
+
1395
+ For an inferred Vercel preview callback, the adapter reads the current
1396
+ invocation's `x-vercel-oidc-token` for each publish. When it is a parseable JWT
1397
+ whose expiration remains at least 60 seconds after the task's first due time,
1398
+ the adapter supplies it as `x-vercel-trusted-oidc-idp-token` and requests QStash
1399
+ header redaction. A matching Vercel Trusted Sources rule can then admit the
1400
+ first delivery. QStash receives the credential even though its dashboard and
1401
+ API redact it.
1402
+
1403
+ This is opportunistic, not durable. Missing, malformed, and near-expiry tokens
1404
+ fall back to an ordinary publish, preserving public-preview behavior. An
1405
+ explicit `url` opts out. Inferred production and local callbacks never forward
1406
+ the token. QStash stores the static header and reuses it on retries, so delayed
1407
+ tasks and retries can outlive it. Use an explicit automation bypass or public
1408
+ ingress when every protected delivery must remain authorized. Applications can
1409
+ supply a dedicated bypass through `headers`; A2 requests redaction for its
1410
+ value, but pending messages retain the captured credential after rotation. A
1411
+ delivery-time Vercel relay remains a separate application architecture that
1412
+ requires a Trusted Sources rule, strict target allowlisting, and a versioned
1413
+ routing envelope. QStash signature verification remains mandatory after the
1414
+ edge-access check.
1415
+
1416
+ Settled deliveries return 200. Busy deliveries await a durable successor
1417
+ watchdog before returning 200. Stalled deliveries publish a fresh successor
1418
+ before returning 200, which resets the per-message retry budget. A retryable
1419
+ store outage also hands both drain and append tasks to a fresh successor before
1420
+ acknowledging. Store-outage successors wait 30 seconds, then multiply the delay
1421
+ by four for each signed generation, capped at one day. Their generation keeps
1422
+ increasing after the cap so each handoff has a new deduplication id. Drain
1423
+ lineage includes the first failing task's `dueAt`, contract, and session. Append
1424
+ lineage includes its task id, contract, and session. Duplicate delivery of one
1425
+ generation therefore publishes the same successor key without merging a later
1426
+ drain episode. Append tasks otherwise return 200 after their ordinary fixed-id
1427
+ append succeeds and any recovery watchdog for pending handler work is durable.
1428
+ Unknown contracts, missing scheduler configuration, schema failures, and
1429
+ successor publish failures keep the current delivery retryable. Delivery is at
1430
+ least once, so handler side effects still need idempotency keys.
1431
+
1432
+ The adapter leaves `retries` unset by default so QStash applies its provider
1433
+ default. An explicit non-negative value is passed through. Every delivery
1434
+ attempt is billable. A prolonged outage in which QStash cannot reach the
1435
+ callback still requires DLQ monitoring and redrive because no running callback
1436
+ can publish a successor. QStash's
1437
+ documented 10-minute deduplication window can reduce duplicate watchdogs but is
1438
+ not part of A2's correctness model. Stable scheduled-event ids also make timer
1439
+ redelivery safe after that window. One-shot delays are currently capped at 7
1440
+ days on free plans and 1 year on usage-based plans, with no fixed maximum on
1441
+ fixed plans. Leave at least one second of margin at a plan maximum because
1442
+ `notBefore` uses whole Unix seconds. The adapter carries both recovery watchdogs and
1443
+ `session.schedule` tasks.
1444
+
1445
+ The built-in QStash and Vercel adapters preserve the original provider error
1446
+ from a rejected send. A recognized definite 4xx status or error is terminal
1447
+ except for 408, 409, 425, and 429. Those statuses, 5xx responses, network
1448
+ failures, and errors with no recognized status remain retryable. The Vercel
1449
+ adapter also recognizes the SDK's name-only `BadRequestError`,
1450
+ `UnauthorizedError`, and `ForbiddenError` as terminal. Its native
1451
+ duplicate-message error is success. A2's durable AI tool execution uses this
1452
+ private classification. Direct `session.schedule()` calls still reject with
1453
+ the original error object.
1454
+
1455
+ ## `a2 devtools`
1456
+
1457
+ The package installs an `a2` binary with a read-only `devtools` namespace.
1458
+
1459
+ | Command | Purpose |
1460
+ | --- | --- |
1461
+ | `contracts` | List contracts mounted at the Devtools URL |
1462
+ | `sessions --contract NAME` | List session summaries, with optional `--limit` and `--cursor` |
1463
+ | `show --contract NAME --session ID` | Read one session's exact durable detail |
1464
+ | `export --contract NAME --session ID` | Verify and save an `.a2log`; use `--output` and opt into replacement with `--force` |
1465
+ | `check --contract NAME --session ID` | Verify a live capture and optionally require `--settled`, `--no-dead-letters`, `--no-caught-failures`, or `--max-redispatches N` |
1466
+ | `verify FILE` | Verify a saved capture offline |
1467
+
1468
+ Live commands read the mounted URL from `--url` or `A2_DEVTOOLS_URL`.
1469
+ `A2_DEVTOOLS_TOKEN` supplies the default bearer token. `--bearer-env ENV` and
1470
+ repeatable `--header-env HEADER=ENV` reference other environment variables.
1471
+ Credential values are never accepted as CLI arguments or URL components.
1472
+ Output uses `--format human`, `json`, or `ndjson`.
1473
+
1474
+ ## `experimental-a2/testing`
1475
+
1476
+ Node-only helpers for verified capture loading and pure reducer replay.
1477
+
1478
+ | Export | Contract |
1479
+ | --- | --- |
1480
+ | `loadCapture(source, contract)` | Read a filesystem path or URL, verify its integrity, validate its contract and payloads, and return typed events |
1481
+ | `prepareCapture(capture, contract)` | Apply the same verification and contract typing to an already parsed capture |
1482
+ | `replayCapture(loaded, reducer, { throughIndex? })` | Fold captured events through the reducer, stopping at an optional inclusive index |
1483
+
1484
+ `LoadedCapture` contains the verified `capture`, the `contract`, typed public
1485
+ `events` with `createdAt` revived as `Date`, and unmodified operational
1486
+ `rawEvents`.
1487
+ `CaptureReplay` returns `{ state, index }`. A cutpoint of 0 returns the initial
1488
+ state and index 0. Supporting types are `CaptureSource`, `LoadedCapture`,
1489
+ `ReplayCaptureOptions`, and `CaptureReplay`.
845
1490
 
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.
1491
+ Replay does not use cached snapshot state, construct a server, dispatch a
1492
+ handler, invoke an AI model or tool, or repeat external effects. It reproduces
1493
+ only the current reducer's pure projection over already recorded events.
849
1494
 
850
1495
  ## Entry points
851
1496
 
@@ -855,18 +1500,22 @@ dashboard returns 501 for those logs.
855
1500
  | `experimental-a2/server` | `createServer`: implement a contract | none |
856
1501
  | `experimental-a2/client` | `createClient`: framework-agnostic session client | none |
857
1502
  | `experimental-a2/react` | `createReact` | `react` |
858
- | `experimental-a2/ai` | agent contract, reducer, and append builders: isomorphic | `ai` |
1503
+ | `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
859
1504
  | `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-sqlite` | SQLite log backend | none |
864
- | `experimental-a2/log-memory` | in-memory log backend | none |
865
- | `experimental-a2/recovery-vercel` | `vercelQueues` recovery | `@vercel/queue` |
1505
+ | `experimental-a2/http` | route-side transport and scheduler helpers | none |
1506
+ | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
1507
+ | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
1508
+ | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
1509
+ | `experimental-a2/store-sqlite` | SQLite store backend | none |
1510
+ | `experimental-a2/store-memory` | in-memory store backend | none |
1511
+ | `experimental-a2/scheduler-vercel` | `vercelQueues`: recovery and timers | `@vercel/queue` |
1512
+ | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
866
1513
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
867
1514
  | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
1515
+ | `experimental-a2/devtools` | versioned wire protocol and exact capture codec | none |
868
1516
  | `experimental-a2/devtools/server` | durable read-only dashboard handler | none |
1517
+ | `experimental-a2/testing` | Node-only capture loading and pure reducer replay | none |
869
1518
 
870
1519
  Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
871
- test in CI, not just convention. Importing `experimental-a2/log-postgres` is what
1520
+ test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
872
1521
  pulls in Postgres, never `experimental-a2` itself, and never your client bundle.