experimental-a2 0.0.0 → 0.1.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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-BWffWe5A.js +867 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +42 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +191 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +36 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +280 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -60,9 +60,14 @@ default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
60
60
  model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
61
61
  model for each generation.
62
62
 
63
- `generation` accepts the remaining AI SDK `ToolLoopAgent` settings: sampling,
64
- token limits, stop conditions, provider options, step callbacks, and tool
65
- approval policy. Individual providers decide which settings they support.
63
+ Each durable generation runs one AI SDK `streamText()` step. `generation`
64
+ contains per-step settings such as sampling, token limits, provider options,
65
+ and tool approval policy. A2 owns the stop condition, local tool execution,
66
+ and continuation between steps. `maxSteps` limits one complete assistant
67
+ response and defaults to 20. Individual providers decide which model settings
68
+ they support. `generation` therefore excludes `stopWhen`, tool execution
69
+ callbacks, tool callers, tool context, sandbox execution, and the tool approval
70
+ secret. Model-step timeouts are supported; tool-execution timeouts are not.
66
71
 
67
72
  `experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
68
73
  contract, reducer, schemas, and pure inputs; it never imports a model provider
@@ -106,9 +111,10 @@ export async function POST(req: Request): Promise<Response> {
106
111
  }
107
112
  ```
108
113
 
109
- The route never calls the model directly. Appending
110
- `ai.generation.requested` wakes the built-in handler, which runs the AI SDK on
111
- the server and appends progress back to the same log.
114
+ The route never calls the model directly. The browser appends user facts such
115
+ as `ai.message.created`. Built-in server handlers schedule
116
+ `ai.generation.requested`, run the AI SDK, and append progress back to the same
117
+ log.
112
118
 
113
119
  ## Bind the reducer to React
114
120
 
@@ -182,13 +188,7 @@ export async function openAgent(
182
188
  message: UIMessage,
183
189
  router: { push(href: string): void },
184
190
  ): Promise<void> {
185
- await assistantClient.session(sessionId).push(
186
- {
187
- type: 'ai.session.created',
188
- payload: { metadata: {} },
189
- },
190
- ...inputs.message(message),
191
- )
191
+ await assistantClient.session(sessionId).push(...inputs.message(message))
192
192
  router.push(`/agent/${sessionId}`)
193
193
  }
194
194
  ```
@@ -216,7 +216,7 @@ export function AgentClient() {
216
216
  const [draft, setDraft] = useState('')
217
217
  const [error, setError] = useState<string | null>(null)
218
218
 
219
- const canSend = state.status === 'idle' || state.status === 'failed'
219
+ const canSend = state.status !== 'closed'
220
220
  const lastMessage = state.messages.at(-1)
221
221
  const lastText =
222
222
  lastMessage?.parts
@@ -289,16 +289,21 @@ export function AgentClient() {
289
289
  }
290
290
  ```
291
291
 
292
- There is no separate message state to reconcile. `push()` folds
293
- `ai.message.created` and `ai.generation.requested` locally before starting the
294
- request. The user message and generating state appear immediately. If the
295
- request fails, A2 removes both optimistic events; the component only restores
296
- the draft.
292
+ There is no separate message state to reconcile. `push()` folds the
293
+ `ai.message.created` fact locally before starting the request, so the user
294
+ message appears immediately. If the request fails, A2 removes that optimistic
295
+ event and the component restores the draft. The server owns the corresponding
296
+ generation request.
297
297
 
298
298
  The form gives Enter its normal submit behavior. The thinking row is also
299
- derived from `AIState`: it appears with the Agent label as soon as the
300
- optimistic generation request folds, then the first visible assistant content
301
- replaces it. Empty stream-start messages never create a blank conversation row.
299
+ derived from `AIState`: it appears with the Agent label when the server's
300
+ generation request folds, then the first visible assistant content replaces
301
+ it. Empty stream-start messages never create a blank conversation row.
302
+
303
+ Users can send another message while a response is active. The message enters
304
+ the durable log immediately, but its turn waits until the active assistant
305
+ response, including every tool step and approval, reaches a terminal event.
306
+ Queued messages keep log order.
302
307
 
303
308
  ## What happens after `push()`
304
309
 
@@ -306,26 +311,52 @@ One user interaction becomes a durable sequence:
306
311
 
307
312
  ```text
308
313
  browser ai.message.created optimistic, then durable
309
- browser ai.generation.requested optimistic, then durable
314
+ server ai.generation.requested scheduled from durable facts
310
315
  server ai.generation.started
311
316
  server ai.generation.progress batched UIMessageChunk[]
312
317
  server ai.tool.called when present
313
- server ai.tool.result when present
314
318
  server ai.approval.requested when present
315
- server ai.generation.completed
319
+ server ai.tool.result after concurrent execution or denial
320
+ server ai.generation.completed closes this model step
321
+ server ai.generation.requested after all sibling tools are terminal
322
+ ... another one-step generation
316
323
  server ai.message.completed
324
+ server ai.generation.requested next queued user turn, when present
317
325
  ```
318
326
 
327
+ Tool results and generation completion may arrive in either order. A fast
328
+ automatic tool can finish while the model stream is still open. The join waits
329
+ for both step closure and every required terminal result.
330
+
319
331
  The SSE connection delivers the server events to the same reducer. Progress
320
332
  stores every AI SDK chunk once. The reducer and the exported
321
333
  `deriveUIMessages()` apply those chunks synchronously to produce the current
322
334
  `UIMessage`; cumulative message snapshots are not duplicated in the log.
323
335
 
324
- `AIState` exposes `messages`, `status`, `activeGeneration`, `activeProjection`,
336
+ `AIState` exposes `messages`, `status`, `activeGeneration`, `activeRequestId`,
337
+ `activeResponseMessageId`, `activeProjection`, `responseGenerationIds`,
325
338
  `pendingApprovals`, `pendingInputs`, `tools`, `compaction`, per-generation
326
- `usage`, and the last generation `error`. `activeProjection` is the temporary
327
- indexed chunk/tool frontier used for exact interruption and becomes `null` at
328
- a terminal event.
339
+ `usage`, and the last generation `error`.
340
+ `activeProjection` is the temporary indexed chunk/tool frontier used for exact
341
+ interruption and becomes `null` at a terminal event.
342
+
343
+ `activeRequestId` identifies the server-authorized generation request. It
344
+ prevents a delayed request or recovered attempt from taking ownership from the
345
+ current response before its generation starts.
346
+
347
+ `activeResponseMessageId` identifies that request's response before
348
+ `activeGeneration` exists. It is `null` after the generation starts.
349
+
350
+ `responseGenerationIds` keeps the latest generation owner for each response
351
+ message. Late lifecycle events from a superseded generation stay in raw
352
+ history but cannot alter the projected message, even after its replacement
353
+ finishes.
354
+
355
+ Generation requests share one durable lane, so model calls do not overlap.
356
+ The queued-turn policy is the stronger ordering rule: a later user message is
357
+ not scheduled until the current response's complete tool loop finishes. Lane
358
+ FIFO alone would not prevent a newly appended user turn from overtaking a tool
359
+ continuation that has not been appended yet.
329
360
 
330
361
  ## The built-in inputs
331
362
 
@@ -333,27 +364,32 @@ a terminal event.
333
364
 
334
365
  | Input | Events |
335
366
  | --- | --- |
336
- | `inputs.message(message)` | records a message and requests generation when its role is `user` |
337
- | `inputs.seed(message)` | records a message without requesting generation |
338
- | `inputs.approval(response)` | records an approval decision and requests continuation |
339
- | `inputs.input(response)` | records application input and requests continuation |
340
- | `inputs.requestInput(request)` | records an application-defined input request |
341
- | `inputs.retry(options)` | requests a fresh attempt after a failed partial response |
367
+ | `inputs.message(message)` | records a message fact; the server schedules user turns |
368
+ | `inputs.seed(message)` | records a trusted server message without scheduling a turn |
369
+ | `inputs.approval(response)` | records an approval decision fact |
370
+ | `inputs.input(response)` | records an application input response fact |
371
+ | `inputs.requestInput(request)` | records a trusted server request for application input |
372
+ | `inputs.retry(options)` | records `ai.retry.requested` for a failed response |
342
373
  | `inputs.interrupt(options)` | interrupts an active response |
343
374
 
344
375
  The builders hide stable event ids, so the same interaction is safe to resend.
345
- They return plain values accepted by both browser `push()` and server
346
- `append()`.
376
+ Browser `push()` accepts user messages, approval and input responses,
377
+ interruptions, and explicit retries. `inputs.seed()` and
378
+ `inputs.requestInput()` are for trusted server appends. Browser inputs never
379
+ append server scheduling, seeded non-user messages, or lifecycle events.
380
+ Approval and input request/response payloads carry the active `generationId`;
381
+ clients copy it from the pending request so a stale interaction cannot satisfy
382
+ a later model step.
347
383
 
348
384
  `inputs` deliberately has no session lifecycle methods. The A2 log begins with
349
385
  the first append. Apps that use explicit `ai.session.created` or
350
- `ai.session.closed` lifecycle events push those ordinary contract events
386
+ `ai.session.closed` lifecycle events append those trusted server facts
351
387
  directly.
352
388
 
353
389
  ## Add tools and approval
354
390
 
355
391
  Pass ordinary AI SDK tools to the server. Approval policy belongs in
356
- `generation`, beside the other `ToolLoopAgent` settings:
392
+ `generation`, beside the other per-step settings:
357
393
 
358
394
  ```ts server/with-tools.ts
359
395
  import { tool } from 'ai'
@@ -405,6 +441,7 @@ export function ApprovalControls() {
405
441
  void push(
406
442
  ...inputs.approval({
407
443
  messageId: approval.messageId,
444
+ generationId: approval.generationId,
408
445
  approvalId: approval.approvalId,
409
446
  approved: true,
410
447
  }),
@@ -418,6 +455,7 @@ export function ApprovalControls() {
418
455
  void push(
419
456
  ...inputs.approval({
420
457
  messageId: approval.messageId,
458
+ generationId: approval.generationId,
421
459
  approvalId: approval.approvalId,
422
460
  approved: false,
423
461
  reason: 'Denied by the user',
@@ -432,9 +470,10 @@ export function ApprovalControls() {
432
470
  }
433
471
  ```
434
472
 
435
- `inputs.approval()` updates the approval part in the projected `UIMessage` and
436
- requests another model turn. The server then executes the approved tool or
437
- returns the denial to the model.
473
+ `inputs.approval()` records only the decision and updates the approval part in
474
+ the projected `UIMessage`. A built-in handler executes the approved tool or
475
+ records a denied result. It never bypasses the same sibling join used by
476
+ automatic tools.
438
477
 
439
478
  Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
440
479
  execution errors, denials, and preliminary and final outputs become durable
@@ -443,11 +482,36 @@ output completes it. Each result has its own event id, so a preliminary result
443
482
  cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
444
483
  metadata, and tool metadata fields are preserved in the projection.
445
484
 
485
+ Independent tool handlers run concurrently as soon as their durable inputs and
486
+ authorization are ready. There is no fixed tool concurrency limit. A private
487
+ coordinator reducer tracks generation closure, cancellation, calls, approvals,
488
+ and terminal results for the active response. Its retained state is bounded;
489
+ completed responses do not accumulate in the coordinator. Each join reads a
490
+ durable reducer snapshot plus the log tail through `ctx.session.state()`. The snapshot
491
+ is only a cache. Recovery can rebuild the same coordinator state from the event
492
+ log after process death.
493
+
494
+ A continuation needs the generation to close and every sibling tool to have a
495
+ terminal success, error, or denied result. Racing join handlers call
496
+ `ctx.session.append()` with the same deterministic continuation ID, so append
497
+ idempotency resolves the race. A handler's single-owner terminal result is
498
+ returned and committed atomically with that handler's completion. Preliminary
499
+ streaming tool outputs use immediate appends. An approved provider-executed
500
+ call can satisfy the join through its response. A provider tool that declares
501
+ deferred-result support can wait for its provider result instead of a local
502
+ executor. The authenticated provider callback appends that `ai.tool.result`
503
+ through the trusted server session API. Browser `push()` rejects lifecycle
504
+ events, including provider results.
505
+
446
506
  `inputs.input()` provides the same durable request/response shape for
447
507
  application-defined input. Its value stays in raw history and
448
508
  `state.pendingInputs`; resolve instructions dynamically or replace `generate`
449
509
  when the value should become model context.
450
510
 
511
+ Approval and application-input request/response facts carry the active
512
+ `generationId`. Echo that value from the pending request so a delayed UI action
513
+ cannot authorize or resume a later model step.
514
+
451
515
  ## Interrupt and retry
452
516
 
453
517
  Interrupt the active response from the client:
@@ -489,10 +553,18 @@ keeps the partial response the user actually saw, and turns incomplete visible
489
553
  tools into `output-error`. Progress, completion, or failure from that generation
490
554
  cannot reactivate it after the interruption.
491
555
 
556
+ `generationId` is optional only between `ai.generation.requested` and
557
+ `ai.generation.started`, when no generation id exists yet. In that phase, use
558
+ `state.activeResponseMessageId` as `messageId` and omit `generationId`. Once
559
+ `activeGeneration` exists, copy both ids from it so a delayed interruption
560
+ cannot stop a later generation.
561
+
492
562
  After a failed model call, `inputs.retry({ messageId, responseMessageId,
493
- retryId })` starts a fresh attempt. `retryId` identifies the user's action, so
494
- resending one retry is safe while a later retry remains distinct. Failed
495
- partial progress remains in raw history but is excluded from the new prompt.
563
+ retryId })` records a retry request. The server schedules its fresh attempt.
564
+ `retryId` identifies the user's action, so resending one retry is safe while a
565
+ later retry remains distinct. Failed partial progress remains in raw history
566
+ but is excluded from the new prompt. Later user messages remain queued while
567
+ the failed response awaits an explicit retry or interruption.
496
568
 
497
569
  ## Append from the server
498
570
 
@@ -567,7 +639,10 @@ different server assembly:
567
639
  ```ts server/custom.ts
568
640
  import { z } from 'zod'
569
641
  import { agent } from 'experimental-a2/ai'
570
- import { createHandlers } from 'experimental-a2/ai/server'
642
+ import {
643
+ createHandlers,
644
+ validateAgentPush,
645
+ } from 'experimental-a2/ai/server'
571
646
  import { createServer } from 'experimental-a2/server'
572
647
 
573
648
  const supportAgent = agent({
@@ -584,6 +659,7 @@ const aiHandlers = createHandlers({
584
659
 
585
660
  export const customAssistantServer = createServer({
586
661
  contract: supportAgent.contract,
662
+ validatePush: validateAgentPush,
587
663
  handlers: {
588
664
  ...aiHandlers,
589
665
  'ticket.linked': async ({ event }) => {
@@ -596,16 +672,18 @@ export const customAssistantServer = createServer({
596
672
 
597
673
  Application events stay fully typed. The standard AI reducer ignores unknown
598
674
  events, so another reducer can project application state without forking the AI
599
- protocol.
675
+ protocol. `validateAgentPush` preserves the same browser boundary as
676
+ `createAgentServer()`: user facts may enter through a parsed push, while model
677
+ scheduling, tool lifecycle, and trusted seed messages stay server-authored.
600
678
 
601
679
  ### Replace generation, not durability
602
680
 
603
- The default generator uses the AI SDK `ToolLoopAgent`. Replace only that
604
- model-facing step for fixtures, provider routing, or a different AI SDK
605
- assembly:
681
+ The default generator uses AI SDK `streamText()` for one model step. Replace
682
+ only that model-facing step for fixtures, provider routing, or a different AI
683
+ SDK assembly:
606
684
 
607
685
  ```ts server/custom-generation.ts
608
- import { ToolLoopAgent, createAgentUIStream } from 'ai'
686
+ import { convertToModelMessages, streamText } from 'ai'
609
687
  import { createAgentServer } from 'experimental-a2/ai/server'
610
688
  import { assistant } from '../assistant'
611
689
 
@@ -615,26 +693,20 @@ export const customGenerationServer = createAgentServer({
615
693
  generate: async ({
616
694
  messages,
617
695
  model,
618
- tools,
619
696
  instructions,
620
- generation,
621
- generationId,
622
697
  responseMessageId,
623
698
  signal,
624
699
  }) => {
625
- const sdkAgent = new ToolLoopAgent<never, typeof tools>({
626
- ...generation,
627
- id: generationId,
700
+ const result = streamText({
628
701
  model,
629
- tools,
702
+ messages: await convertToModelMessages(messages),
703
+ abortSignal: signal,
630
704
  ...(instructions === undefined ? {} : { instructions }),
631
705
  })
632
706
 
633
- return createAgentUIStream({
634
- agent: sdkAgent,
635
- uiMessages: messages,
707
+ return result.toUIMessageStream({
708
+ originalMessages: messages,
636
709
  generateMessageId: () => responseMessageId,
637
- abortSignal: signal,
638
710
  })
639
711
  },
640
712
  })
@@ -642,17 +714,26 @@ export const customGenerationServer = createAgentServer({
642
714
 
643
715
  `generate` receives compacted messages, resolved model and instructions, tools,
644
716
  generation settings, durable request identity, current state and history, and
645
- the abort signal. It returns one `ReadableStream<UIMessageChunk>`. A2 still
646
- records progress, tool and approval events, completion, interruption, and
647
- failure.
717
+ the abort signal. It returns exactly one model step as a
718
+ `ReadableStream<UIMessageChunk>`. A tool-aware replacement sends definitions to
719
+ the model without running local `execute` functions. A2 still owns durable
720
+ progress, tool execution, approval, continuation, interruption, and failure.
648
721
 
649
722
  ## Delivery semantics
650
723
 
651
- Model calls and tools run inside an at-least-once A2 handler. Durable event ids
652
- make lifecycle appends effectively once, and A2 marks an incomplete attempt
653
- `superseded` before starting its replacement. Model providers and external tool
654
- side effects remain outside the log. Give side-effecting tools their own
655
- idempotency strategy, normally keyed by the AI SDK tool call id.
724
+ Model steps and tools run inside at-least-once A2 handlers. The returned-event
725
+ transaction makes each successful handler completion and its terminal events
726
+ one atomic durable operation. Deterministic ids make scheduling and lifecycle
727
+ appends effectively once, and A2 marks an incomplete model attempt
728
+ `superseded` before starting its replacement.
729
+
730
+ Provider calls and external tool side effects remain outside that transaction.
731
+ A process can die after an external effect succeeds and before its handler
732
+ completion commits, so recovery may run the call again. Give side-effecting
733
+ tools their own idempotency strategy, normally keyed by the AI SDK tool call
734
+ id. This is the same at-least-once boundary as every other A2 handler. A fast
735
+ tool may also start before its model step later fails. The failure prevents a
736
+ continuation, but it cannot roll back that external effect.
656
737
 
657
738
  Configure [production recovery](/guides/production) exactly as for any other A2
658
739
  server. Recovery wakes an interrupted generation after the original serverless
@@ -44,7 +44,7 @@ returning a `Response` supports a redirect or authentication challenge.
44
44
  ## Causal forest
45
45
 
46
46
  Every stored `cause` connects a child to the event index and handler attempt
47
- whose `ctx.append` first persisted it. The dashboard builds that forest
47
+ whose `ctx.session.append` first persisted it. The dashboard builds that forest
48
48
  directly from the session log. It does not need a trace table or another write
49
49
  on the append path.
50
50
 
@@ -75,10 +75,13 @@ dashboard shows the first dispatch time as unknown instead of moving it to
75
75
  `createdAt` or `lastClaimedAt`.
76
76
 
77
77
  The bar is event lifetime, not handler execution time. A2 stores lifecycle
78
- summaries, not one span row for every attempt. Time spent waiting, running a
79
- handler, coordinating a lease, or waiting for recovery remains one honest
78
+ summaries, not one span row for every attempt. Time spent waiting for a lane,
79
+ running a handler, holding a claim, or waiting for recovery remains one honest
80
80
  interval.
81
81
 
82
+ An event type without a handler is complete at append. Its bar has no waiting
83
+ time or dispatch marker, and its attempt count stays at zero.
84
+
82
85
  Snapshots appear as their reducer name, event frontier, and last update time.
83
86
  The dashboard does not send cached snapshot state to the browser.
84
87
 
@@ -34,9 +34,9 @@ orders / order-43
34
34
  agents / run-abc
35
35
  ```
36
36
 
37
- The session is the boundary A2 orders, recovers, folds, and streams. Put events
38
- in the same session when they must be ordered or viewed together. Split work
39
- that should progress independently.
37
+ The session is the boundary A2 logs, recovers, folds, and streams. Put events
38
+ in the same session when they form one history or view. Handler execution is
39
+ concurrent unless events share a lane.
40
40
 
41
41
  A contract is not a table. A session is not a table either. One contract
42
42
  usually serves many sessions, and one session may describe changes that touch
@@ -77,8 +77,7 @@ a cost: boards and search span many sessions and need another query model. If
77
77
  those collection views are the center of the product, let the database lead.
78
78
 
79
79
  Do not put an entire workspace in one session just to make cross-issue queries
80
- possible. Every event in one session is ordered together, and every client of
81
- that session follows the same growing log.
80
+ possible. Every event joins the same growing log and every client follows it.
82
81
 
83
82
  ## Database writes from handlers
84
83
 
@@ -99,7 +98,7 @@ integration:
99
98
 
100
99
  - it is eventually consistent with the session logs;
101
100
  - writes must be idempotent;
102
- - A2 orders events within one session, not across sessions;
101
+ - A2 assigns log order within one session, not across sessions;
103
102
  - A2 does not expose one API that scans and replays every session to rebuild
104
103
  the index.
105
104
 
package/docs/index.mdx CHANGED
@@ -10,7 +10,7 @@ pnpm add experimental-a2
10
10
  A2 is durable sync and reactions for things with a lifecycle.
11
11
 
12
12
  An order, agent run, approval, or import gets a session. You append events.
13
- Handlers react to them, and usually append the next one. The browser follows
13
+ Handlers react to them, and usually return the next one. The browser follows
14
14
  the same log and folds the same state as the server. That's the whole model;
15
15
  the rest of this page is it happening.
16
16
 
@@ -41,7 +41,7 @@ export const orders = a2.contract({
41
41
 
42
42
  Handlers are plain async functions: no determinism rules, no replay, no
43
43
  wrappers around side effects. Each one reacts to a fact and usually
44
- appends the next one.
44
+ returns the next one.
45
45
 
46
46
  ```ts server/orders.ts
47
47
  import { createServer } from 'experimental-a2/server'
@@ -50,10 +50,10 @@ import { orders } from '@/contracts'
50
50
  export const ordersServer = createServer({
51
51
  contract: orders,
52
52
  handlers: {
53
- created: async ({ event, append }) => {
53
+ created: async ({ event }) => {
54
54
  // your side effect; event.id makes a stable idempotency key:
55
55
  // await notifyShop(event.payload, { idempotencyKey: event.id })
56
- await append({ type: 'shop.notified', payload: {} })
56
+ return { type: 'shop.notified', payload: {} }
57
57
  },
58
58
  },
59
59
  })
@@ -61,8 +61,8 @@ export const ordersServer = createServer({
61
61
 
62
62
  ## Open a session
63
63
 
64
- A session is one instance of the contract, with its own log and its own
65
- ordering. It is the thing A2 keeps durable and live. The id is yours:
64
+ A session is one instance of the contract, with its own ordered log. It is the
65
+ thing A2 keeps durable and live. The id is yours:
66
66
 
67
67
  - `session('order-8194')`: an order moving through fulfillment
68
68
  - `session('chat-a8c1')`: an agent chat streaming its reply
@@ -70,7 +70,8 @@ ordering. It is the thing A2 keeps durable and live. The id is yours:
70
70
  - `session('doc-roadmap')`: a document with three people typing in it
71
71
 
72
72
  Anything whose story spans more than one request. Put events in the same
73
- session when they must be ordered or viewed together. Any server code can open
73
+ session when they form one history or view. Handlers are concurrent unless
74
+ they share a lane. Any server code can open
74
75
  one and append: a route handler, a webhook, a cron job.
75
76
 
76
77
  ```ts app/api/orders/route.ts
@@ -165,11 +166,13 @@ twenty lines; [Live UI](/guides/react) wires it end to end.
165
166
 
166
167
  ## The properties
167
168
 
168
- - **Handlers are plain async functions.** If it runs on your laptop, it
169
- runs in production.
169
+ - **Handlers are plain async functions.** They run concurrently by default.
170
+ Use a lane for selective FIFO execution.
171
+ - **Handlers are optional per event type.** A log-only event settles during
172
+ append and creates no reaction or recovery work.
170
173
  - **History is real.** [`history()`](/concepts/state) returns what
171
174
  actually happened, in order. Debugging is reading, not reconstructing.
172
- - **Nothing inside a session is sacred except its log.** Snapshots, leases, queue
175
+ - **Nothing inside a session is sacred except its log.** Snapshots, claims, queue
173
176
  messages: all disposable caches. Delete them and A2 rebuilds
174
177
  everything from the log.
175
178
 
@@ -201,6 +204,18 @@ framework. A contract, a log, handlers, reducers.
201
204
 
202
205
  </details>
203
206
 
207
+ <details>
208
+ <summary>Can I use A2 without handlers?</summary>
209
+
210
+ Yes. Omit `handlers` and use `append`, `history`, reducers, and live streams as
211
+ a durable event log. Events settle in their append transaction, with no drain,
212
+ claim, or queue message. A contract can also mix handled and log-only event
213
+ types. A2 decides per event type.
214
+
215
+ See [Events without handlers](/concepts/handlers#events-without-handlers).
216
+
217
+ </details>
218
+
204
219
  <details>
205
220
  <summary>How do I wait five days?</summary>
206
221
 
@@ -223,8 +238,8 @@ derived view exactly. See [Cancellation](/guides/cancellation).
223
238
  <details>
224
239
  <summary>What happens when a handler keeps failing?</summary>
225
240
 
226
- It retries with backoff, then dead-letters after ten caught failures, and the
227
- session stalls at that event, visibly, instead of silently skipping it.
241
+ It retries with backoff, then dead-letters after ten caught failures. Later
242
+ events in the same lane wait; unlaned events and other lanes continue.
228
243
  [Durability](/concepts/durability) has the exact guarantees and the
229
244
  recovery story behind them.
230
245
 
@@ -233,8 +248,9 @@ recovery story behind them.
233
248
  <details>
234
249
  <summary>What does production need?</summary>
235
250
 
236
- Two decisions: a log backend (Postgres or Redis) and queue-backed
237
- recovery. Development needs neither. SQLite appears under `.a2/` and
251
+ A durable log backend, such as Postgres or Redis. Add queue-backed recovery
252
+ when the contract has handlers. A log-only contract needs no reaction
253
+ infrastructure. Development needs neither: SQLite appears under `.a2/` and
238
254
  state survives restarts. See [Going to production](/guides/production).
239
255
 
240
256
  </details>