experimental-a2 0.6.0 → 0.7.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 (145) hide show
  1. package/CHANGELOG.md +22 -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 +8 -7
  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-286j79Mt.js} +708 -79
  27. package/dist/server-286j79Mt.js.map +1 -0
  28. package/dist/{server-DpvjhdoE.d.ts → server-DgXmORIq.d.ts} +67 -49
  29. package/dist/server-DgXmORIq.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-flRz1OWh.d.ts} +2 -57
  34. package/dist/store-flRz1OWh.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 +1 -59
  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.js +1 -81
  43. package/dist/store-postgres.js.map +1 -1
  44. package/dist/{store-redis-core-BFLwz0Wj.js → store-redis-core-DEYO8Ryv.js} +48 -134
  45. package/dist/store-redis-core-DEYO8Ryv.js.map +1 -0
  46. package/dist/store-redis-http.d.ts +1 -1
  47. package/dist/store-redis-http.js +2 -3
  48. package/dist/store-redis-http.js.map +1 -1
  49. package/dist/store-redis.d.ts +1 -1
  50. package/dist/store-redis.js +3 -4
  51. package/dist/store-redis.js.map +1 -1
  52. package/dist/store-sqlite.d.ts +1 -1
  53. package/dist/store-sqlite.d.ts.map +1 -1
  54. package/dist/store-sqlite.js +1 -72
  55. package/dist/store-sqlite.js.map +1 -1
  56. package/dist/{wire-BFQmSJ-9.js → wire-B6te_wns.js} +4 -3
  57. package/dist/wire-B6te_wns.js.map +1 -0
  58. package/docs/guides/03-react.mdx +20 -28
  59. package/docs/guides/05-production.mdx +9 -11
  60. package/docs/guides/06-ai-agents.mdx +8 -14
  61. package/docs/guides/09-presence.mdx +14 -19
  62. package/docs/guides/10-transports.mdx +104 -86
  63. package/docs/reference/01-api.mdx +143 -277
  64. package/docs/reference/02-errors.mdx +5 -7
  65. package/package.json +1 -14
  66. package/src/ai-server.ts +9 -5
  67. package/src/client.ts +2 -2
  68. package/src/errors.ts +1 -0
  69. package/src/internal.ts +3 -62
  70. package/src/push-envelope.ts +24 -21
  71. package/src/scheduler-qstash.ts +3 -3
  72. package/src/scheduler-vercel.ts +2 -2
  73. package/src/server-fetch.ts +344 -0
  74. package/src/server.ts +73 -225
  75. package/src/session-socket.ts +36 -20
  76. package/src/sse.ts +2 -2
  77. package/src/store-memory.ts +0 -81
  78. package/src/store-postgres.ts +0 -100
  79. package/src/store-redis-core.ts +47 -211
  80. package/src/store-redis-http.ts +0 -1
  81. package/src/store-redis.ts +0 -1
  82. package/src/store-sqlite.ts +0 -119
  83. package/src/store.ts +0 -60
  84. package/src/wire.ts +2 -1
  85. package/dist/ai-D_PGS-JR.d.ts +0 -334
  86. package/dist/ai-D_PGS-JR.d.ts.map +0 -1
  87. package/dist/cli-B3VuxoDe.js +0 -599
  88. package/dist/cli-B3VuxoDe.js.map +0 -1
  89. package/dist/cli-bin.d.ts +0 -1
  90. package/dist/cli-bin.js +0 -7
  91. package/dist/cli-bin.js.map +0 -1
  92. package/dist/cli.d.ts +0 -20
  93. package/dist/cli.d.ts.map +0 -1
  94. package/dist/cli.js +0 -2
  95. package/dist/client-CdMqi7mC.d.ts +0 -202
  96. package/dist/client-CdMqi7mC.d.ts.map +0 -1
  97. package/dist/client-Dj5d3SP_.js +0 -1026
  98. package/dist/client-Dj5d3SP_.js.map +0 -1
  99. package/dist/devtools-J_jZ2vQf.d.ts +0 -152
  100. package/dist/devtools-J_jZ2vQf.d.ts.map +0 -1
  101. package/dist/devtools-kJJaORn-.js +0 -340
  102. package/dist/devtools-kJJaORn-.js.map +0 -1
  103. package/dist/devtools-server.browser.d.ts +0 -1
  104. package/dist/devtools-server.browser.js +0 -6
  105. package/dist/devtools-server.browser.js.map +0 -1
  106. package/dist/devtools-server.d.ts +0 -23
  107. package/dist/devtools-server.d.ts.map +0 -1
  108. package/dist/devtools-server.js +0 -1270
  109. package/dist/devtools-server.js.map +0 -1
  110. package/dist/devtools.d.ts +0 -2
  111. package/dist/devtools.js +0 -2
  112. package/dist/errors-W6nwJ-fm.d.ts +0 -21
  113. package/dist/errors-W6nwJ-fm.d.ts.map +0 -1
  114. package/dist/http.d.ts +0 -151
  115. package/dist/http.d.ts.map +0 -1
  116. package/dist/http.js +0 -706
  117. package/dist/http.js.map +0 -1
  118. package/dist/inspection-DaxB5jM2.js +0 -13
  119. package/dist/inspection-DaxB5jM2.js.map +0 -1
  120. package/dist/internal-DstsI6Re.js.map +0 -1
  121. package/dist/platform-B4TnJtWu.js +0 -34
  122. package/dist/platform-B4TnJtWu.js.map +0 -1
  123. package/dist/server-DpvjhdoE.d.ts.map +0 -1
  124. package/dist/server-Duw6MVlB.js.map +0 -1
  125. package/dist/store-DysUkTH3.d.ts.map +0 -1
  126. package/dist/store-redis-core-BFLwz0Wj.js.map +0 -1
  127. package/dist/testing.browser.d.ts +0 -1
  128. package/dist/testing.browser.js +0 -6
  129. package/dist/testing.browser.js.map +0 -1
  130. package/dist/testing.d.ts +0 -32
  131. package/dist/testing.d.ts.map +0 -1
  132. package/dist/testing.js +0 -103
  133. package/dist/testing.js.map +0 -1
  134. package/dist/wire-BFQmSJ-9.js.map +0 -1
  135. package/docs/guides/07-devtools.mdx +0 -229
  136. package/src/cli-bin.ts +0 -5
  137. package/src/cli.ts +0 -1046
  138. package/src/devtools-app.ts +0 -989
  139. package/src/devtools-server.browser.ts +0 -5
  140. package/src/devtools-server.ts +0 -604
  141. package/src/devtools.ts +0 -716
  142. package/src/http.ts +0 -394
  143. package/src/inspection.ts +0 -39
  144. package/src/testing.browser.ts +0 -5
  145. 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
@@ -203,6 +189,136 @@ retries may move the frontier between them. A generic join should use a
203
189
  monotone readiness predicate and a stable explicit output event `id`, so every
204
190
  eligible attempt converges on the same append.
205
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.
321
+
206
322
  ### `server.session(id)`
207
323
 
208
324
  ```ts
@@ -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.
@@ -537,7 +653,7 @@ session.stream(options: {
537
653
 
538
654
  A live feed of the session's events. `startAfter` is a non-negative safe integer;
539
655
  `startAfter: 20` begins with event 21.
540
- Server-side only; `handle` exposes it over SSE, and over the multiplexed
656
+ Server-side only; `server.fetch` exposes it over SSE, and over the multiplexed
541
657
  socket when `upgrade` is set. Subscribing never dispatches handlers.
542
658
 
543
659
  With `presence: true`, the feed yields one `PresenceSnapshot` first:
@@ -581,7 +697,7 @@ the storage clock, not the sender stamp, so a hostile stamp can only
581
697
  vandalize its own field and still expires on schedule.
582
698
 
583
699
  The patch is structurally the `presence` sibling of the push envelope;
584
- `handle`'s push lane forwards it whole to `session.setPresence(presence)`.
700
+ `server.fetch`'s push lane forwards it whole to `session.setPresence(presence)`.
585
701
  The API mirrors the wire.
586
702
 
587
703
  The handler-scoped form `ctx.session.setPresence(...)` is the same
@@ -707,9 +823,7 @@ Both members exist only when the contract declares `presence`; their
707
823
  value and field types come from its schemas, through the reducer, with
708
824
  no type arguments (the reducer is the client's typed handle on the
709
825
  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
826
+ `participant`: the client's default, or the provider's override. See
713
827
  [Presence](/guides/presence).
714
828
 
715
829
  `push` appends optimistically: validated locally, rolled back on
@@ -1045,17 +1159,10 @@ it into `createServer({ handlers })` beside application handlers when you need
1045
1159
  a custom assembly. The table handles input facts, generation requests, model
1046
1160
  step completion, tool calls, approval responses, and terminal tool results.
1047
1161
  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.
1162
+ handler. A custom assembly owns its browser ingress policy. Use
1163
+ `server.fetch(request, { authorize })` to reject server-authored AI event names
1164
+ before they reach the server. `createAgentServer()` installs the built-in
1165
+ browser allowlist automatically.
1059
1166
 
1060
1167
  See [Durable AI agents](/guides/ai-agents) for the protocol and complete
1061
1168
  examples.
@@ -1106,96 +1213,6 @@ frontier. Idle handles are evicted after `gcTime`. This memory layer is
1106
1213
  separate from `experimental-a2/cache-indexeddb`: memory preserves identity
1107
1214
  across route transitions, while IndexedDB preserves the replica across reloads.
1108
1215
 
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
1216
  ## `experimental-a2/cache-indexeddb`
1200
1217
 
1201
1218
  ```ts
@@ -1273,112 +1290,6 @@ log.
1273
1290
  renames and additions are breaking for dashboards, and are called out in the
1274
1291
  package's `CHANGELOG.md`.
1275
1292
 
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
1293
  ## `experimental-a2/scheduler-vercel`
1383
1294
 
1384
1295
  `vercelQueues(options?)` returns an `A2Scheduler` backed by Vercel Queues.
@@ -1490,10 +1401,9 @@ Mount the application handler once and pass every server that shares the
1490
1401
  scheduler:
1491
1402
 
1492
1403
  ```ts app/api/a2/scheduler/route.ts
1493
- import { schedulerHandler } from 'experimental-a2/http'
1494
- import { ordersServer, billingServer } from '@/server'
1404
+ import { billingServer, ordersServer, scheduler } from '@/server'
1495
1405
 
1496
- export const POST = schedulerHandler(ordersServer, billingServer)
1406
+ export const POST = scheduler.handler(ordersServer, billingServer)
1497
1407
  ```
1498
1408
 
1499
1409
  The route must be reachable by the selected QStash server after any platform
@@ -1574,46 +1484,6 @@ duplicate-message error is success. A2's durable AI tool execution uses this
1574
1484
  private classification. Direct `session.schedule()` calls still reject with
1575
1485
  the original error object.
1576
1486
 
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
1487
  ## Entry points
1618
1488
 
1619
1489
  | Entry point | Ships | Peer dependency |
@@ -1624,7 +1494,6 @@ only the current reducer's pure projection over already recorded events.
1624
1494
  | `experimental-a2/react` | `createReact` | `react` |
1625
1495
  | `experimental-a2/ai` | agent contract, `handlerContext`, reducer, and append builders: isomorphic | `ai` |
1626
1496
  | `experimental-a2/ai/server` | AI SDK runner and built-in handlers | `ai` |
1627
- | `experimental-a2/http` | route-side transport and scheduler helpers | none |
1628
1497
  | `experimental-a2/store-postgres` | `postgres`: Postgres store backend | `pg` (or inject a client) |
1629
1498
  | `experimental-a2/store-redis` | `redis`: Redis Streams store backend, push-native streaming | `ioredis` (or inject a client) |
1630
1499
  | `experimental-a2/store-redis-http` | `redisHttp`: the same Redis store over provider REST APIs (Upstash) | none |
@@ -1634,9 +1503,6 @@ only the current reducer's pure projection over already recorded events.
1634
1503
  | `experimental-a2/scheduler-qstash` | `qstash`: signed recovery and timers | `@upstash/qstash` |
1635
1504
  | `experimental-a2/cache-indexeddb` | `indexedDb` browser cache | none |
1636
1505
  | `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
1506
 
1641
1507
  Core `experimental-a2` imports none of the backends, enforced by a browser-bundle
1642
1508
  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.
package/package.json CHANGED
@@ -1,12 +1,9 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Durable sync and reactions for things with a lifecycle: one event log, derived state, and live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "bin": {
8
- "a2": "./dist/cli-bin.js"
9
- },
10
7
  "sideEffects": false,
11
8
  "engines": {
12
9
  "node": ">=22.13"
@@ -39,7 +36,6 @@
39
36
  "browser": "./dist/ai-server.browser.js",
40
37
  "default": "./dist/ai-server.js"
41
38
  },
42
- "./http": "./dist/http.js",
43
39
  "./store-memory": "./dist/store-memory.js",
44
40
  "./store-sqlite": "./dist/store-sqlite.js",
45
41
  "./store-postgres": "./dist/store-postgres.js",
@@ -49,15 +45,6 @@
49
45
  "./scheduler-vercel": "./dist/scheduler-vercel.js",
50
46
  "./cache-indexeddb": "./dist/cache-indexeddb.js",
51
47
  "./otel": "./dist/otel.js",
52
- "./devtools": "./dist/devtools.js",
53
- "./devtools/server": {
54
- "browser": "./dist/devtools-server.browser.js",
55
- "default": "./dist/devtools-server.js"
56
- },
57
- "./testing": {
58
- "browser": "./dist/testing.browser.js",
59
- "default": "./dist/testing.js"
60
- },
61
48
  "./package.json": "./package.json"
62
49
  },
63
50
  "peerDependencies": {
package/src/ai-server.ts CHANGED
@@ -57,11 +57,14 @@ import {
57
57
  createServer,
58
58
  type A2Server,
59
59
  type HandlerContext,
60
- type PushValidationContext,
61
60
  type ServerOptions,
62
61
  } from './server.ts'
62
+ import {
63
+ setServerFetchHooks,
64
+ type ServerIngressContext,
65
+ } from './server-fetch.ts'
63
66
 
64
- export function validateAgentPush(context: PushValidationContext): void {
67
+ function validateAgentIngress(context: ServerIngressContext): void {
65
68
  const rejected = context.events.find((event) => {
66
69
  if (event.type !== 'ai.message.created') {
67
70
  return !(
@@ -203,7 +206,7 @@ export type CreateAgentServerOptions<
203
206
  D extends AIEventDefs<M> & EventDefs,
204
207
  T extends ToolSet = ToolSet,
205
208
  > = CreateHandlersOptions<M, D, T> &
206
- Omit<ServerOptions<D>, 'contract' | 'handlers' | 'validatePush'> & {
209
+ Omit<ServerOptions<D>, 'contract' | 'handlers'> & {
207
210
  handlers?: ServerOptions<D>['handlers']
208
211
  }
209
212
 
@@ -1708,12 +1711,13 @@ export function createAgentServer<
1708
1711
  ...(compaction === undefined ? {} : { compaction }),
1709
1712
  ...(progress === undefined ? {} : { progress }),
1710
1713
  })
1711
- return createServer({
1714
+ const server = createServer({
1712
1715
  ...serverOptions,
1713
1716
  contract: definition.contract,
1714
1717
  handlers: { ...builtIns, ...handlers },
1715
- validatePush: validateAgentPush,
1716
1718
  })
1719
+ setServerFetchHooks(server, { validateIngress: validateAgentIngress })
1720
+ return server
1717
1721
  }
1718
1722
 
1719
1723
  export type { Instructions, LanguageModel, ToolSet }
package/src/client.ts CHANGED
@@ -808,7 +808,7 @@ const wsTransport = (
808
808
  settleChannel(
809
809
  channel,
810
810
  new Error(
811
- 'subscribe not confirmed: is the ws route serving the multiplexed socket (handle with options.upgrade)?',
811
+ 'subscribe not confirmed: is the ws route calling server.fetch with upgradeWebSocket?',
812
812
  ),
813
813
  )
814
814
  }, STREAM_TIMINGS.stallTimeoutMs)
@@ -1562,7 +1562,7 @@ export function createClient<
1562
1562
  const readHistory = transport.history
1563
1563
  if (readHistory === undefined) {
1564
1564
  throw new TypeError(
1565
- 'loadHistory requires an http api — the ws transport has no history lane; serve the session over handle() and pass its route url as the string or { type: "http" } api',
1565
+ 'loadHistory requires an http api — the ws transport has no history lane; serve the session with server.fetch and pass its route url as the string or { type: "http" } api',
1566
1566
  )
1567
1567
  }
1568
1568
  const before = loadOptions?.before
package/src/errors.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  export type A2ErrorCode =
10
10
  | 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
11
+ | 'FORBIDDEN' // external ingress was denied by server.fetch authorization
11
12
  | 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
12
13
  | 'PARTIAL_DUPLICATE_BATCH' // batch mixed already-appended and fresh events
13
14
  | 'SUPERSEDED_ATTEMPT' // handler append from an attempt a recovery claim replaced