experimental-a2 0.6.0 → 0.8.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 (148) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/ai-server.d.ts +4 -5
  3. package/dist/ai-server.d.ts.map +1 -1
  4. package/dist/ai-server.js +20 -17
  5. package/dist/ai-server.js.map +1 -1
  6. package/dist/ai.d.ts +334 -2
  7. package/dist/ai.d.ts.map +1 -0
  8. package/dist/ai.js +1 -1
  9. package/dist/client.d.ts +202 -2
  10. package/dist/client.d.ts.map +1 -0
  11. package/dist/client.js +1025 -1
  12. package/dist/client.js.map +1 -0
  13. package/dist/errors-BQuJpe82.js.map +1 -1
  14. package/dist/index.d.ts +22 -3
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/{internal-DstsI6Re.js → internal-DRXJ56EI.js} +5 -28
  17. package/dist/internal-DRXJ56EI.js.map +1 -0
  18. package/dist/react.d.ts +1 -1
  19. package/dist/react.js +1 -1
  20. package/dist/scheduler-qstash.d.ts +3 -3
  21. package/dist/scheduler-qstash.js +4 -5
  22. package/dist/scheduler-qstash.js.map +1 -1
  23. package/dist/scheduler-vercel.d.ts +2 -2
  24. package/dist/scheduler-vercel.js +4 -4
  25. package/dist/scheduler-vercel.js.map +1 -1
  26. package/dist/{server-Duw6MVlB.js → server-B2XNevQA.js} +830 -131
  27. package/dist/server-B2XNevQA.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DjPhHnbI.d.ts} +71 -50
  29. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  30. package/dist/server.d.ts +3 -3
  31. package/dist/server.js +1 -1
  32. package/dist/store-N8PXxDAS.js.map +1 -1
  33. package/dist/{store-DysUkTH3.d.ts → store-RJO35BMj.d.ts} +24 -62
  34. package/dist/store-RJO35BMj.d.ts.map +1 -0
  35. package/dist/store-memory.d.ts +1 -1
  36. package/dist/store-memory.d.ts.map +1 -1
  37. package/dist/store-memory.js +80 -78
  38. package/dist/store-memory.js.map +1 -1
  39. package/dist/{store-polling-dSeLxzfb.js → store-polling-6DW7F1DT.js} +2 -2
  40. package/dist/{store-polling-dSeLxzfb.js.map → store-polling-6DW7F1DT.js.map} +1 -1
  41. package/dist/store-postgres.d.ts +1 -1
  42. package/dist/store-postgres.d.ts.map +1 -1
  43. package/dist/store-postgres.js +231 -182
  44. package/dist/store-postgres.js.map +1 -1
  45. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DT01r4GZ.js} +213 -161
  46. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  47. package/dist/store-redis-http.d.ts +1 -1
  48. package/dist/store-redis-http.js +3 -4
  49. package/dist/store-redis-http.js.map +1 -1
  50. package/dist/store-redis.d.ts +1 -1
  51. package/dist/store-redis.js +4 -5
  52. package/dist/store-redis.js.map +1 -1
  53. package/dist/store-sqlite.d.ts +1 -1
  54. package/dist/store-sqlite.d.ts.map +1 -1
  55. package/dist/store-sqlite.js +104 -91
  56. package/dist/store-sqlite.js.map +1 -1
  57. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  58. package/dist/wire-B6te_wns.js.map +1 -0
  59. package/docs/concepts/02-handlers.mdx +4 -0
  60. package/docs/concepts/04-state.mdx +57 -9
  61. package/docs/guides/03-react.mdx +20 -28
  62. package/docs/guides/05-production.mdx +9 -11
  63. package/docs/guides/06-ai-agents.mdx +10 -15
  64. package/docs/guides/09-presence.mdx +14 -19
  65. package/docs/guides/10-transports.mdx +104 -86
  66. package/docs/reference/01-api.mdx +182 -293
  67. package/docs/reference/02-errors.mdx +5 -7
  68. package/package.json +1 -14
  69. package/src/ai-server.ts +36 -15
  70. package/src/client.ts +2 -2
  71. package/src/errors.ts +1 -0
  72. package/src/internal.ts +3 -62
  73. package/src/push-envelope.ts +24 -21
  74. package/src/scheduler-qstash.ts +3 -3
  75. package/src/scheduler-vercel.ts +2 -2
  76. package/src/server-fetch.ts +344 -0
  77. package/src/server.ts +315 -312
  78. package/src/session-socket.ts +36 -20
  79. package/src/sse.ts +2 -2
  80. package/src/store-memory.ts +138 -101
  81. package/src/store-postgres.ts +355 -238
  82. package/src/store-redis-core.ts +247 -237
  83. package/src/store-redis-http.ts +1 -2
  84. package/src/store-redis.ts +1 -2
  85. package/src/store-sqlite.ts +191 -153
  86. package/src/store.ts +24 -66
  87. package/src/wire.ts +2 -1
  88. package/dist/ai-D_PGS-JR.d.ts +0 -334
  89. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  90. package/dist/cli-B3VuxoDe.js +0 -599
  91. package/dist/cli-B3VuxoDe.js.map +0 -1
  92. package/dist/cli-bin.d.ts +0 -1
  93. package/dist/cli-bin.js +0 -7
  94. package/dist/cli-bin.js.map +0 -1
  95. package/dist/cli.d.ts +0 -20
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js +0 -2
  98. package/dist/client-CdMqi7mC.d.ts +0 -202
  99. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  100. package/dist/client-Dj5d3SP_.js +0 -1026
  101. package/dist/client-Dj5d3SP_.js.map +0 -1
  102. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  103. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  104. package/dist/devtools-kJJaORn-.js +0 -340
  105. package/dist/devtools-kJJaORn-.js.map +0 -1
  106. package/dist/devtools-server.browser.d.ts +0 -1
  107. package/dist/devtools-server.browser.js +0 -6
  108. package/dist/devtools-server.browser.js.map +0 -1
  109. package/dist/devtools-server.d.ts +0 -23
  110. package/dist/devtools-server.d.ts.map +0 -1
  111. package/dist/devtools-server.js +0 -1270
  112. package/dist/devtools-server.js.map +0 -1
  113. package/dist/devtools.d.ts +0 -2
  114. package/dist/devtools.js +0 -2
  115. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  116. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  117. package/dist/http.d.ts +0 -151
  118. package/dist/http.d.ts.map +0 -1
  119. package/dist/http.js +0 -706
  120. package/dist/http.js.map +0 -1
  121. package/dist/inspection-DaxB5jM2.js +0 -13
  122. package/dist/inspection-DaxB5jM2.js.map +0 -1
  123. package/dist/internal-DstsI6Re.js.map +0 -1
  124. package/dist/platform-B4TnJtWu.js +0 -34
  125. package/dist/platform-B4TnJtWu.js.map +0 -1
  126. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  127. package/dist/server-Duw6MVlB.js.map +0 -1
  128. package/dist/store-DysUkTH3.d.ts.map +0 -1
  129. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  130. package/dist/testing.browser.d.ts +0 -1
  131. package/dist/testing.browser.js +0 -6
  132. package/dist/testing.browser.js.map +0 -1
  133. package/dist/testing.d.ts +0 -32
  134. package/dist/testing.d.ts.map +0 -1
  135. package/dist/testing.js +0 -103
  136. package/dist/testing.js.map +0 -1
  137. package/dist/wire-BFQmSJ-9.js.map +0 -1
  138. package/docs/guides/07-devtools.mdx +0 -229
  139. package/src/cli-bin.ts +0 -5
  140. package/src/cli.ts +0 -1046
  141. package/src/devtools-app.ts +0 -989
  142. package/src/devtools-server.browser.ts +0 -5
  143. package/src/devtools-server.ts +0 -604
  144. package/src/devtools.ts +0 -716
  145. package/src/http.ts +0 -394
  146. package/src/inspection.ts +0 -39
  147. package/src/testing.browser.ts +0 -5
  148. package/src/testing.ts +0 -185
@@ -72,7 +72,6 @@ createServer(options: {
72
72
  store?: A2Store // default: sqlite in dev, memory in tests, required in prod
73
73
  scheduler?: A2Scheduler
74
74
  telemetry?: A2Telemetry // optional instrumentation; see experimental-a2/otel
75
- validatePush?: (context: PushValidationContext) => void | PromiseLike<void>
76
75
  presence?: { ttlMs?: number } // presence expiry policy; default 60s
77
76
  handlers?: {
78
77
  [type]:
@@ -123,19 +122,6 @@ Server-only by construction: `experimental-a2/server` is the only entry point th
123
122
  can reach a store backend, and its exports map resolves to a loud error
124
123
  under the browser condition.
125
124
 
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
136
- the runtime provenance brand after reading the envelope; a caller-supplied
137
- field with the same name is ignored, and the brand is not stored in the log.
138
-
139
125
  `presence.ttlMs` sets how long a presence value survives without a
140
126
  refreshing set (default 60 seconds, as a positive integer of
141
127
  milliseconds). Expiry counts on the storage's own clock, never on the
@@ -172,12 +158,11 @@ The context every handler receives:
172
158
  when a process dies before handler entry.
173
159
 
174
160
  `ctx.session.id` equals `ctx.event.sessionId`. Its `history`, `state`, and
175
- `stream` methods are the same session operations returned by
176
- `server.session(id)`. `ctx.session.state(reducer)` reads the cached snapshot
177
- plus immutable log tail and returns `{ state, index }`. The index is a
178
- consistent committed frontier captured when the call runs. It includes the
179
- triggering event and may include events committed later while another handler
180
- is active.
161
+ `stream` methods share the session surface returned by `server.session(id)`,
162
+ with one causal default: `ctx.session.state(reducer)` folds through the
163
+ triggering event's index. The same event sees the same state across concurrent
164
+ appends and retries. Ask for `{ through: 'latest' }` when a handler intentionally
165
+ needs the committed frontier captured when that read runs.
181
166
 
182
167
  The handler-local append is specialized:
183
168
 
@@ -198,10 +183,141 @@ in its stable task identity. A relative delay is anchored to that event's
198
183
  durable `createdAt`, so a handler retry keeps the same delivery time. The call
199
184
  returns `Promise<void>` after the configured scheduler accepts the task.
200
185
 
201
- A state read and following append are not atomic. Concurrent appends and
202
- retries may move the frontier between them. A generic join should use a
203
- monotone readiness predicate and a stable explicit output event `id`, so every
204
- eligible attempt converges on the same append.
186
+ A latest state read and following append are not atomic. Concurrent appends and
187
+ retries may move the frontier between them. A generic join should read with
188
+ `{ through: 'latest' }`, use a monotone readiness predicate, and give its output
189
+ a stable explicit event `id`, so every eligible attempt converges on the same
190
+ append.
191
+
192
+ ### `server.fetch(request, options?)`
193
+
194
+ ```ts
195
+ server.fetch(
196
+ request: Request,
197
+ options?: {
198
+ authorize?: (operation: A2Operation) => boolean | Promise<boolean>
199
+ upgradeWebSocket?: (
200
+ attach: (socket: A2Socket) => void,
201
+ ) => Response | Promise<Response>
202
+ },
203
+ ): Promise<Response>
204
+
205
+ type A2Operation =
206
+ | {
207
+ type: 'stream'
208
+ sessionId: string
209
+ startAfter: number
210
+ transport: 'http' | 'websocket'
211
+ }
212
+ | {
213
+ type: 'history'
214
+ sessionId: string
215
+ gte: number
216
+ lte: number
217
+ transport: 'http'
218
+ }
219
+ | {
220
+ type: 'push'
221
+ sessionId: string
222
+ events: readonly {
223
+ type: ContractEventType | string
224
+ payload: unknown
225
+ id?: string
226
+ }[]
227
+ presence?: {
228
+ participant: string
229
+ values: Readonly<Record<string, unknown>>
230
+ seen?: number
231
+ at?: number
232
+ }
233
+ transport: 'http' | 'websocket'
234
+ }
235
+ ```
236
+
237
+ The bound Fetch API route for this server. An unprotected route can export it
238
+ directly:
239
+
240
+ ```ts app/api/public-order-events/route.ts
241
+ import { ordersServer } from '@/server/orders'
242
+
243
+ export const GET = ordersServer.fetch
244
+ export const POST = ordersServer.fetch
245
+ ```
246
+
247
+ A plain GET streams one session over SSE, resumed after the `index` query
248
+ parameter. A GET with `gte` and `lte` returns the closed history range as JSON
249
+ and sets `a2-history-covered` to `true` when every requested index was returned.
250
+ POST accepts `{ sessionId, events, presence? }` and returns the appended events.
251
+ Presence is inferred from the contract. A contract that declares presence
252
+ streams it and accepts presence pushes automatically.
253
+
254
+ `authorize` runs after the request or socket frame has been parsed, but before
255
+ contract schema validation, presence validation, or I/O. Event names and
256
+ presence field names are typed from the contract. Their unvalidated values stay
257
+ `unknown`. Return `false` to deny the operation with `FORBIDDEN`. A thrown error
258
+ becomes `STORE_UNAVAILABLE`, because authorization infrastructure failed rather
259
+ than denied access. Operation objects, push events, event arrays, and presence
260
+ patches are frozen. Authorize them, do not rewrite them.
261
+
262
+ Authentication stays outside A2, where the route can read headers, cookies, and
263
+ framework context. Capture the authenticated principal in `authorize`:
264
+
265
+ ```ts app/api/protected-order-events/route.ts
266
+ import { ordersServer } from '@/server/orders'
267
+
268
+ async function fetchOrders(request: Request): Promise<Response> {
269
+ // your authentication:
270
+ const user = { id: 'user-1' }
271
+ if (!user) return new Response(null, { status: 401 })
272
+
273
+ return ordersServer.fetch(request, {
274
+ authorize(operation) {
275
+ // your per-session and per-operation authorization:
276
+ // return canAccess(user, operation)
277
+ return true
278
+ },
279
+ })
280
+ }
281
+
282
+ export const GET = fetchOrders
283
+ export const POST = fetchOrders
284
+ ```
285
+
286
+ Direct `session.append`, handler appends, scheduled appends, and server-side
287
+ `setPresence` bypass `authorize`. It is an ingress policy for this fetch call,
288
+ not a global server policy.
289
+
290
+ A GET with `Upgrade: websocket` uses `upgradeWebSocket`. Without that option it
291
+ returns 426. The upgrade function receives an `attach` callback for the
292
+ platform socket:
293
+
294
+ ```ts app/api/socket-order-events/route.ts
295
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
296
+ import { ordersServer } from '@/server/orders'
297
+
298
+ export const POST = ordersServer.fetch
299
+
300
+ export function GET(request: Request): Promise<Response> {
301
+ return ordersServer.fetch(request, {
302
+ upgradeWebSocket: (attach) =>
303
+ experimental_upgradeWebSocket(attach, {
304
+ maxPayload: 4 * 1024 * 1024,
305
+ }),
306
+ })
307
+ }
308
+ ```
309
+
310
+ Authenticate the physical WebSocket GET before calling `server.fetch`. Then
311
+ use `authorize` for each session subscribe and each event or presence push.
312
+ There is no WebSocket-upgrade operation in `A2Operation`. A denied subscribe
313
+ receives `unsubscribed` and does not affect other sessions. A denied event push
314
+ receives a `FORBIDDEN` ack. Denied presence is silently dropped because it is
315
+ fire-and-forget. Failed presence authorization is dropped the same way and does
316
+ not disconnect unrelated sessions on the multiplexed socket.
317
+
318
+ SSE and WebSocket lifetimes use the platform's ambient invocation deadline.
319
+ A2 also uses the platform's ambient `waitUntil` capability for background work.
320
+ Neither capability is reordered or passed through `server.fetch` options.
205
321
 
206
322
  ### `server.session(id)`
207
323
 
@@ -372,9 +488,9 @@ Custom scheduler adapters implement two methods:
372
488
  | `schedule(task: SchedulerTask): Promise<void>` | put one versioned task on durable delivery infrastructure |
373
489
  | `handler(...servers)` | return the route that receives drain and append tasks |
374
490
 
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.
491
+ Application routes call `scheduler.handler(...servers)` on the configured
492
+ adapter. The handler requires at least one drainable server and rejects
493
+ duplicate contract names synchronously.
378
494
 
379
495
  `SchedulerTask` is `SchedulerDrainTask | SchedulerAppendTask`. These task types,
380
496
  plus `ScheduledEvent`, are exported from `experimental-a2/server`.
@@ -429,7 +545,7 @@ the contract's schemas before anything is written. A multi-event append
429
545
  is atomic: all-or-nothing, consecutive positions, one transaction. Pass
430
546
  `id` to make an append idempotent across retries; re-sending an
431
547
  identical batch returns the original rows. (Events that arrived through
432
- `handle`'s push lane are accepted directly.) See
548
+ `server.fetch`'s push lane are accepted directly.) See
433
549
  [Durability](/concepts/durability).
434
550
 
435
551
  After the write, A2 dispatches only event types with registered handlers.
@@ -514,10 +630,13 @@ Raw events from the session, oldest first. `gte` and `lte` are inclusive event
514
630
  indexes; omit a bound to leave that end of the log open.
515
631
  Bounds are non-negative safe integers. `lte: 0` returns `[]`; `gte > lte` throws.
516
632
 
517
- ### `session.state(reducer)`
633
+ ### `session.state(reducer, options?)`
518
634
 
519
635
  ```ts
520
- session.state(reducer: Reducer<S>): Promise<{ state: S; index: number }>
636
+ session.state(
637
+ reducer: Reducer<S>,
638
+ options?: { through?: number | 'latest' },
639
+ ): Promise<{ state: S; index: number }>
521
640
  ```
522
641
 
523
642
  The log folded through one committed prefix. `index` is its exact boundary,
@@ -525,6 +644,24 @@ an append-order cursor rather than a handler-settlement marker. Hand it to the
525
644
  client to resume there. Snapshot write-back uses platform `waitUntil`; this
526
645
  read never dispatches handlers or waits for pending work.
527
646
 
647
+ On a root session from `server.session(id)`, omitting `through` reads the latest
648
+ committed frontier. On the handler-scoped `ctx.session`, omitting it reads
649
+ through `ctx.event.index`. That boundary is inclusive and stable across retries.
650
+ Use `{ through: 'latest' }` inside a handler only when later committed events
651
+ are intentionally part of the decision. A numeric `through` is an inclusive,
652
+ non-negative safe integer; `through: 0` returns the reducer's initial state.
653
+
654
+ The default handler read and numeric handler reads automatically retain their
655
+ exact folded checkpoint while the triggering event is unfinished. Completion
656
+ or dead-lettering releases the reference and collects the checkpoint when no
657
+ other unfinished handler uses it. There are no retention options. Handler
658
+ `{ through: 'latest' }` reads and all root reads do not retain history.
659
+
660
+ If the latest snapshot is ahead of a numeric boundary, A2 can use the greatest
661
+ eligible retained checkpoint. A root historical read never creates retention
662
+ or writes a stale checkpoint behind the latest snapshot; it folds from the log
663
+ when no eligible checkpoint exists.
664
+
528
665
  ### `session.stream(options?)`
529
666
 
530
667
  ```ts
@@ -537,7 +674,7 @@ session.stream(options: {
537
674
 
538
675
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
676
  `startAfter: 20` begins with event 21.
540
- Server-side only; `handle` exposes it over SSE, and over the multiplexed
677
+ Server-side only; `server.fetch` exposes it over SSE, and over the multiplexed
541
678
  socket when `upgrade` is set. Subscribing never dispatches handlers.
542
679
 
543
680
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
@@ -581,7 +718,7 @@ the storage clock, not the sender stamp, so a hostile stamp can only
581
718
  vandalize its own field and still expires on schedule.
582
719
 
583
720
  The patch is structurally the `presence` sibling of the push envelope;
584
- `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
721
+ `server.fetch`'s push lane forwards it whole to `session.setPresence(presence)`.
585
722
  The API mirrors the wire.
586
723
 
587
724
  The handler-scoped form `ctx.session.setPresence(...)` is the same
@@ -707,9 +844,7 @@ Both members exist only when the contract declares `presence`; their
707
844
  value and field types come from its schemas, through the reducer, with
708
845
  no type arguments (the reducer is the client's typed handle on the
709
846
  contract; it still never folds presence). `setPresence` requires a
710
- `participant`: the client's default, or the provider's override. If
711
- the map holds only your own echo with
712
- participants active, the GET route forgot `presence: true`. See
847
+ `participant`: the client's default, or the provider's override. See
713
848
  [Presence](/guides/presence).
714
849
 
715
850
  `push` appends optimistically: validated locally, rolled back on
@@ -1006,10 +1141,12 @@ retried or interrupted.
1006
1141
  Independent authorized tool handlers run concurrently without a fixed
1007
1142
  concurrency limit. A private coordinator reducer tracks generation closure,
1008
1143
  cancellation, calls, approvals, and terminal results for the active response.
1009
- Completed responses do not accumulate in its state. Its `ctx.session.state()` reads the
1010
- durable snapshot plus log tail. Process memory is not authoritative. Concurrent
1011
- join checks return the same deterministic continuation event, so they use
1012
- `ctx.session.append()` and storage deduplicates the race.
1144
+ Completed responses do not accumulate in its state. Its
1145
+ `ctx.session.state(coordinator, { through: 'latest' })` reads the durable
1146
+ snapshot plus log tail because these joins intentionally observe concurrent
1147
+ tool results. Process memory is not authoritative. Concurrent join checks return
1148
+ the same deterministic continuation event, so they use `ctx.session.append()`
1149
+ and storage deduplicates the race.
1013
1150
 
1014
1151
  Tool results and generation completion have no fixed relative order. The
1015
1152
  continuation predicate needs both the closed model step and every required
@@ -1045,17 +1182,10 @@ it into `createServer({ handlers })` beside application handlers when you need
1045
1182
  a custom assembly. The table handles input facts, generation requests, model
1046
1183
  step completion, tool calls, approval responses, and terminal tool results.
1047
1184
  Application handlers spread later can deliberately replace a built-in
1048
- handler. Custom assemblies pass `validateAgentPush` as
1049
- `createServer({ validatePush })` to preserve the browser boundary.
1050
-
1051
- ### `validateAgentPush(context)`
1052
-
1053
- `validateAgentPush({ sessionId, events }): void`
1054
-
1055
- Accepts the browser interaction allowlist: user messages, approval and input
1056
- responses, interruptions, and explicit retries. It rejects server-authored
1057
- scheduling and lifecycle events, seeded non-user messages, and input requests.
1058
- `createAgentServer()` installs it automatically.
1185
+ handler. A custom assembly owns its browser ingress policy. Use
1186
+ `server.fetch(request, { authorize })` to reject server-authored AI event names
1187
+ before they reach the server. `createAgentServer()` installs the built-in
1188
+ browser allowlist automatically.
1059
1189
 
1060
1190
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
1061
1191
  examples.
@@ -1106,96 +1236,6 @@ frontier. Idle handles are evicted after `gcTime`. This memory layer is
1106
1236
  separate from `experimental-a2/cache-indexeddb`: memory preserves identity
1107
1237
  across route transitions, while IndexedDB preserves the replica across reloads.
1108
1238
 
1109
- ## `experimental-a2/http`
1110
-
1111
- ### `handle(server, options?)`
1112
-
1113
- ```ts
1114
- handle(server: A2Server, options?: {
1115
- before?(args: { request: Request; intent: A2Intent }):
1116
- Response | undefined | void | Promise<Response | undefined | void>
1117
- after?(args: { request: Request; intent: A2Intent; outcome: A2Outcome; response: Response }):
1118
- Response | undefined | void | Promise<Response | undefined | void>
1119
- upgrade?: UpgradeFn // e.g. (attach) => experimental_upgradeWebSocket(attach)
1120
- presence?: boolean // interleave presence on every stream lane
1121
- deadline?: number // epoch ms: close sockets cleanly before it
1122
- }): { GET(req: Request): Promise<Response>; POST(req: Request): Promise<Response> }
1123
-
1124
- type A2Intent =
1125
- | { type: 'ws-upgrade' }
1126
- | { type: 'stream'; sessionId: string; startAfter: number; transport: 'sse' | 'ws' }
1127
- | { type: 'history'; sessionId: string; gte: number; lte: number }
1128
- | { type: 'push'; sessionId: string; events: PushedEvent[]; presence?: PushedPresence; transport: 'http' | 'ws' }
1129
-
1130
- type A2Outcome =
1131
- | { type: 'stream' }
1132
- | { type: 'history'; covered: boolean; events: Event[] }
1133
- | { type: 'push'; appended: Event[] }
1134
- ```
1135
-
1136
- The session route pair as one call: `export const { GET, POST } =
1137
- handle(server)` in a route module (any framework speaking
1138
- `(req: Request) => Promise<Response>`). A plain `GET` is the live SSE
1139
- stream, resumed after the `index` query parameter, with a `: connected`
1140
- prelude, a `: ping` heartbeat every 15s, and a clean close one second
1141
- before an ambient Vercel invocation deadline when available; presence
1142
- patches ride as named frames when `presence: true`. A `GET` with
1143
- `gte`/`lte` query parameters is a history slice: the closed log range
1144
- as JSON wire events, the read `loadHistory` rides. `POST` is the push
1145
- envelope `{ sessionId, events, presence? }`, answered with the appended
1146
- events. A `GET` carrying an upgrade header becomes the multiplexed
1147
- WebSocket when `options.upgrade` is present, and answers `426` when it
1148
- is not.
1149
-
1150
- Parsing is protocol, hooks are policy. A request that fails to parse
1151
- (missing `sessionId`, malformed bounds, a bad push envelope) answers
1152
- `INVALID_PAYLOAD` on the wire before any hook runs. `before` sees every
1153
- parsed intent, HTTP requests and socket frames alike; over the socket,
1154
- each subscribe arrives as a `stream` intent and each push as a `push`
1155
- intent, with `request` always the original upgrade Request. Returning a
1156
- Response short-circuits: over HTTP it is the response, verbatim; over
1157
- the socket it is translated into the wire's own vocabulary (a denied
1158
- subscribe answers `unsubscribed`, a denied push a non-retryable error
1159
- ack), because a Response cannot cross a socket.
1160
-
1161
- `after` runs only where the library produced an HTTP response: never
1162
- after a short-circuit, never for `ws-upgrade` or socket frames. It may
1163
- mutate `response.headers` in place or return a replacement Response.
1164
- `outcome.covered` on a history read means the closed range came back
1165
- fully covered (`events.length === lte - gte + 1`): an immutable slice
1166
- of the append-only log, safe to cache under whatever policy your
1167
- `after` applies. The history response carries no cache headers of its
1168
- own.
1169
-
1170
- The socket is one connection for all of a client's sessions:
1171
- `subscribe`/`unsubscribe` frames open and close per-session lanes at
1172
- their own resume frontiers, `sessionId` tags route pushes, presence,
1173
- and acks, and a lane ending or failing answers `unsubscribed` without
1174
- taking the socket down. See [Transports](/guides/transports).
1175
-
1176
- ### The rest of the entry
1177
-
1178
- | Helper | What it does |
1179
- | ----------------------- | ------------------------------------------------------------------------------ |
1180
- | `schedulerHandler(...servers)` | returns the delivery route after synchronously verifying one shared scheduler |
1181
- | `errorResponse(err)` | serializes an `A2Error` to `{ error: { code, message, details } }` + status; the natural return value of a refusing `before` hook |
1182
- | `deserializeError(body)` | rebuilds an `A2Error` from a wire body, or `null` if the body isn't one |
1183
-
1184
- `schedulerHandler(...servers)` is the application-facing scheduler route. It
1185
- requires at least one A2 server. Every server must have a scheduler, use the
1186
- exact same scheduler instance, and have a unique contract name. Non-A2 values
1187
- also fail. These checks throw before the request handler is returned, so bad
1188
- wiring fails when the route module loads. The helper then delegates delivery
1189
- to the shared adapter's `A2Scheduler.handler(...)` method.
1190
-
1191
- Different scheduler instances use different routes. Match each QStash route to
1192
- that instance's resolved `url`; additional QStash routes pass an explicit
1193
- `url`. Match each Vercel Queues route and trigger to that instance's `topic`.
1194
-
1195
- `errorResponse` and `deserializeError` are the `A2Error` wire format
1196
- that `push` and the push lane share. See
1197
- [Errors](/reference/errors#over-the-wire).
1198
-
1199
1239
  ## `experimental-a2/cache-indexeddb`
1200
1240
 
1201
1241
  ```ts
@@ -1273,112 +1313,6 @@ log.
1273
1313
  renames and additions are breaking for dashboards, and are called out in the
1274
1314
  package's `CHANGELOG.md`.
1275
1315
 
1276
- ## `experimental-a2/devtools`
1277
-
1278
- The shared, isomorphic protocol for browser Devtools, the CLI, and `.a2log`
1279
- captures.
1280
-
1281
- | Export | Contract |
1282
- | --- | --- |
1283
- | `DEVTOOLS_PROTOCOL_VERSION` | Current HTTP wire protocol version |
1284
- | `DEVTOOLS_CAPTURE_VERSION` | Current `.a2log` record format version |
1285
- | `DEVTOOLS_CAPTURE_MEDIA_TYPE` | `application/x-ndjson` |
1286
- | `DEVTOOLS_SESSION_PAGE_LIMIT` | Default event count requested per session page |
1287
- | `DEVTOOLS_SESSION_PAGE_MAX_LIMIT` | Maximum event count accepted per session page |
1288
- | `DEVTOOLS_CAPABILITIES` | Protocol resources and capture format advertised by the server |
1289
- | `devtoolsSessionRevision({ events, snapshots })` | Compute the lifecycle invalidation token after assembling pages |
1290
- | `encodeDevtoolsCapture(detail, { capturedAt? })` | Encode one session detail as exact NDJSON and add its SHA-256 footer |
1291
- | `parseDevtoolsCapture(value)` | Parse NDJSON text or bytes into a `DevtoolsCapture` while retaining unknown fields on known records |
1292
- | `verifyDevtoolsCapture(capture)` | Verify versions, record shapes, session identity, indexes, counts, dates, and the SHA-256 digest |
1293
-
1294
- The wire types are `DevtoolsWireSessionSummary`, `DevtoolsWireEvent`,
1295
- `DevtoolsWireSnapshot`, `DevtoolsContractsResponse`,
1296
- `DevtoolsSessionsResponse`, `DevtoolsSessionDetail`, `DevtoolsSessionPage`, and
1297
- `DevtoolsCapabilities`. Capture records use `DevtoolsCaptureManifest`,
1298
- `DevtoolsCaptureEvent`, `DevtoolsCaptureSnapshot`,
1299
- `DevtoolsCaptureFooter`, and `DevtoolsCaptureRecord`. A parsed
1300
- `DevtoolsCapture` exposes the same canonical sequence through `records`.
1301
-
1302
- ```ts test/verify-a2log.ts
1303
- import { readFile } from 'node:fs/promises'
1304
- import {
1305
- parseDevtoolsCapture,
1306
- verifyDevtoolsCapture,
1307
- } from 'experimental-a2/devtools'
1308
-
1309
- export async function verifyA2Log(path: string) {
1310
- const capture = parseDevtoolsCapture(await readFile(path))
1311
- await verifyDevtoolsCapture(capture)
1312
- return capture
1313
- }
1314
- ```
1315
-
1316
- The manifest records the contract, session, revision, capture time, protocol
1317
- version, and capture version. Event records contain the exact durable payload
1318
- and operational bookkeeping. Snapshot records contain metadata but never
1319
- cached reducer state. The final `end` record contains counts, the highest event
1320
- index, and a digest of every preceding encoded record. The session revision is
1321
- an invalidation token, not the integrity digest. Encoding rejects durable
1322
- values that canonical JSON would coerce or omit.
1323
-
1324
- ## `experimental-a2/devtools/server`
1325
-
1326
- ```ts
1327
- // anywhere on the server:
1328
- import type { DevtoolsServer } from 'experimental-a2/devtools/server'
1329
-
1330
- declare function createDevtools(options: {
1331
- servers: readonly DevtoolsServer[]
1332
- authorize?: (
1333
- request: Request,
1334
- ) => boolean | Response | Promise<boolean | Response>
1335
- }): {
1336
- handler(): (request: Request) => Promise<Response>
1337
- }
1338
- ```
1339
-
1340
- A read-only dashboard over the servers' durable logs. The handler serves the
1341
- complete browser application, its JSON endpoints, and live SSE invalidations.
1342
- It discovers sessions and reads stored causal, dispatch, completion, failure,
1343
- and snapshot metadata. The causal forest and lifecycle timeline come directly
1344
- from stored events. The initial page response includes the selected dashboard
1345
- data so the browser does not need a contracts, sessions, and detail request
1346
- waterfall. It never drains or heals a session.
1347
-
1348
- Every route is a `GET`. The versioned resources are `capabilities`,
1349
- `contracts`, `sessions`, `session`, `watch`, and `export` under the handler's
1350
- `_a2/` path. `export` returns an integrity-checkable `.a2log` with the media
1351
- type from `DEVTOOLS_CAPTURE_MEDIA_TYPE`.
1352
-
1353
- `session` without pagination parameters returns one complete detail. Add a
1354
- positive `limit` to receive a `DevtoolsSessionPage`. Its `throughIndex` is the
1355
- inclusive event frontier frozen by the first request. Pass its opaque `cursor`
1356
- back until the cursor is null. Snapshot metadata appears on the first page
1357
- only. The cursor is bound to its contract and session, and the server rejects
1358
- reuse against another log. The advertised maximum page size is clamped by the
1359
- server.
1360
-
1361
- The browser, CLI, watch loop, and export route walk these bounded pages for
1362
- you. They reject a gap or early end. An exact export includes every event
1363
- through one finite frontier, even when a backend provider limits one range
1364
- response. Events appended during the walk belong to a later read.
1365
-
1366
- Without `authorize`, the handler is available only when `NODE_ENV` is exactly
1367
- `development`. Any other value, including unset, returns 404. When `authorize`
1368
- is present, only a literal `true` grants access. Returning `false` also returns
1369
- 404. Returning a `Response` passes that response through, which supports
1370
- redirects and authentication challenges. The mounted handler is the
1371
- authorization boundary for the browser, CLI, SSE stream, and exact capture
1372
- download. A2 does not add a separate Devtools credential store.
1373
-
1374
- The built-in memory, SQLite, Postgres, and Redis stores support inspection.
1375
- Custom `A2Store` implementations can omit the optional `inspect` interface; the
1376
- dashboard returns 501 for those logs. A custom inspection implementation may
1377
- add `readEvents(sessionId, { afterIndex, throughIndex?, limit })` for efficient
1378
- bounded reads. It returns `{ events, throughIndex }`, where the first call
1379
- freezes the inclusive frontier and continuations preserve it. Without this
1380
- method, A2 reads the complete log for each page and slices it in memory.
1381
-
1382
1316
  ## `experimental-a2/scheduler-vercel`
1383
1317
 
1384
1318
  `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
@@ -1490,10 +1424,9 @@ Mount the application handler once and pass every server that shares the
1490
1424
  scheduler:
1491
1425
 
1492
1426
  ```ts app/api/a2/scheduler/route.ts
1493
- import { schedulerHandler } from 'experimental-a2/http'
1494
- import { ordersServer, billingServer } from '@/server'
1427
+ import { billingServer, ordersServer, scheduler } from '@/server'
1495
1428
 
1496
- export const POST = schedulerHandler(ordersServer, billingServer)
1429
+ export const POST = scheduler.handler(ordersServer, billingServer)
1497
1430
  ```
1498
1431
 
1499
1432
  The route must be reachable by the selected QStash server after any platform
@@ -1574,46 +1507,6 @@ duplicate-message error is success. A2's durable AI tool execution uses this
1574
1507
  private classification. Direct `session.schedule()` calls still reject with
1575
1508
  the original error object.
1576
1509
 
1577
- ## `a2 devtools`
1578
-
1579
- The package installs an `a2` binary with a read-only `devtools` namespace.
1580
-
1581
- | Command | Purpose |
1582
- | --- | --- |
1583
- | `contracts` | List contracts mounted at the Devtools URL |
1584
- | `sessions --contract NAME` | List session summaries, with optional `--limit` and `--cursor` |
1585
- | `show --contract NAME --session ID` | Read one session's exact durable detail |
1586
- | `export --contract NAME --session ID` | Verify and save an `.a2log`; use `--output` and opt into replacement with `--force` |
1587
- | `check --contract NAME --session ID` | Verify a live capture and optionally require `--settled`, `--no-dead-letters`, `--no-caught-failures`, or `--max-redispatches N` |
1588
- | `verify FILE` | Verify a saved capture offline |
1589
-
1590
- Live commands read the mounted URL from `--url` or `A2_DEVTOOLS_URL`.
1591
- `A2_DEVTOOLS_TOKEN` supplies the default bearer token. `--bearer-env ENV` and
1592
- repeatable `--header-env HEADER=ENV` reference other environment variables.
1593
- Credential values are never accepted as CLI arguments or URL components.
1594
- Output uses `--format human`, `json`, or `ndjson`.
1595
-
1596
- ## `experimental-a2/testing`
1597
-
1598
- Node-only helpers for verified capture loading and pure reducer replay.
1599
-
1600
- | Export | Contract |
1601
- | --- | --- |
1602
- | `loadCapture(source, contract)` | Read a filesystem path or URL, verify its integrity, validate its contract and payloads, and return typed events |
1603
- | `prepareCapture(capture, contract)` | Apply the same verification and contract typing to an already parsed capture |
1604
- | `replayCapture(loaded, reducer, { throughIndex? })` | Fold captured events through the reducer, stopping at an optional inclusive index |
1605
-
1606
- `LoadedCapture` contains the verified `capture`, the `contract`, typed public
1607
- `events` with `createdAt` revived as `Date`, and unmodified operational
1608
- `rawEvents`.
1609
- `CaptureReplay` returns `{ state, index }`. A cutpoint of 0 returns the initial
1610
- state and index 0. Supporting types are `CaptureSource`, `LoadedCapture`,
1611
- `ReplayCaptureOptions`, and `CaptureReplay`.
1612
-
1613
- Replay does not use cached snapshot state, construct a server, dispatch a
1614
- handler, invoke an AI model or tool, or repeat external effects. It reproduces
1615
- only the current reducer's pure projection over already recorded events.
1616
-
1617
1510
  ## Entry points
1618
1511
 
1619
1512
  | Entry point | Ships | Peer dependency |
@@ -1624,7 +1517,6 @@ only the current reducer's pure projection over already recorded events.
1624
1517
  | `experimental-a2/react` | `createReact` | `react` |
1625
1518
  | `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
1626
1519
  | `experimental-a2/ai/server` | AI SDK runner and built-in handlers | `ai` |
1627
- | `experimental-a2/http` | route-side transport and scheduler helpers | none |
1628
1520
  | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
1629
1521
  | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
1630
1522
  | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
@@ -1634,9 +1526,6 @@ only the current reducer's pure projection over already recorded events.
1634
1526
  | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
1635
1527
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
1636
1528
  | `experimental-a2/otel` | `otel` telemetry adapter | `@opentelemetry/api` |
1637
- | `experimental-a2/devtools` | versioned wire protocol and exact capture codec | none |
1638
- | `experimental-a2/devtools/server` | durable read-only dashboard handler | none |
1639
- | `experimental-a2/testing` | Node-only capture loading and pure reducer replay | none |
1640
1529
 
1641
1530
  Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
1642
1531
  test in CI, not just convention. Importing `experimental-a2/store-postgres` is what
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Errors
3
- description: One error class, five codes, and clear rules about what append will never throw for.
3
+ description: One error class, stable codes, and clear rules about what append will never throw for.
4
4
  ---
5
5
 
6
6
  ## `A2Error`
@@ -35,6 +35,7 @@ export async function POST(req: Request) {
35
35
  | Code | Thrown when | Retryable |
36
36
  | ------------------------- | ------------------------------------------------------------------ | --------- |
37
37
  | `INVALID_PAYLOAD` | a payload fails its schema; nothing was written, and `details` carries the issues ([Standard Schema](https://standardschema.dev) format) | no |
38
+ | `FORBIDDEN` | `server.fetch` authorization denies a parsed stream, history read, push, or presence update | no |
38
39
  | `UNKNOWN_EVENT_TYPE` | an event type isn't in the contract's `events` map | no |
39
40
  | `UNKNOWN_PRESENCE_FIELD` | a presence field isn't in the contract's `presence` map | no |
40
41
  | `PRESENCE_NOT_SUPPORTED` | the contract declares `presence` but the configured store has no presence capability (thrown at construction; for environment-resolved stores, deferred to the first store use) | no |
@@ -73,12 +74,9 @@ The push route serializes an `A2Error` as:
73
74
  ```
74
75
 
75
76
  with a mapped status: `INVALID_PAYLOAD`, `UNKNOWN_EVENT_TYPE`, and
76
- `PARTIAL_DUPLICATE_BATCH` are 400; `SUPERSEDED_ATTEMPT` is 409;
77
- `STORE_UNAVAILABLE` is 503.
77
+ `PARTIAL_DUPLICATE_BATCH` are 400; `FORBIDDEN` is 403;
78
+ `SUPERSEDED_ATTEMPT` is 409; `STORE_UNAVAILABLE` is 503.
78
79
 
79
80
  The client's `push` deserializes the body back into an `A2Error`, so client
80
81
  and server code branch on identical codes. `push` auto-retries only
81
- `STORE_UNAVAILABLE`. The serializer pair, `errorResponse` and
82
- `deserializeError`, ships in `experimental-a2/http` alongside `handle`;
83
- a `before` hook that wants the wire's own error shapes returns
84
- `errorResponse(new A2Error(...))`.
82
+ `STORE_UNAVAILABLE`. `server.fetch` owns the HTTP serialization.