experimental-a2 0.0.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 (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. package/package.json +111 -0
@@ -0,0 +1,659 @@
1
+ ---
2
+ title: Durable AI agents
3
+ description: Build an AI SDK agent whose messages, streaming progress, tools, approvals, and recovery live in an A2 event log.
4
+ ---
5
+
6
+ ## Install
7
+
8
+ Install A2, the AI SDK, and Zod for the examples below:
9
+
10
+ ```bash
11
+ pnpm add experimental-a2 ai zod
12
+ ```
13
+
14
+ The examples use the AI SDK's default global provider, AI Gateway. Add its key
15
+ to your environment:
16
+
17
+ ```bash .env.local
18
+ AI_GATEWAY_API_KEY=your_api_key
19
+ ```
20
+
21
+ ## Define the agent
22
+
23
+ `agent()` defines an isomorphic event contract and its standard `AIState`
24
+ reducer. The same value types the server, server-rendered state, and optimistic
25
+ client updates.
26
+
27
+ ```ts assistant.ts
28
+ import { agent } from 'experimental-a2/ai'
29
+
30
+ export const assistant = agent({
31
+ name: 'support-agent',
32
+ })
33
+ ```
34
+
35
+ Keep this module isomorphic. Model providers, secrets, tools, and storage belong
36
+ in the server module.
37
+
38
+ ## Connect the model
39
+
40
+ `createAgentServer()` adds the AI SDK runner and A2's ordinary storage,
41
+ recovery, telemetry, and handler machinery:
42
+
43
+ ```ts server/assistant.ts
44
+ import { createAgentServer } from 'experimental-a2/ai/server'
45
+ import { assistant } from '../assistant'
46
+
47
+ export const assistantServer = createAgentServer({
48
+ agent: assistant,
49
+ model: 'openai/gpt-5.6-terra',
50
+ instructions: 'Help the customer. Be concise.',
51
+ generation: {
52
+ temperature: 0.2,
53
+ maxOutputTokens: 2_000,
54
+ },
55
+ })
56
+ ```
57
+
58
+ A string model id uses the AI SDK's global provider, which is AI Gateway by
59
+ default. `model` also accepts any AI SDK `LanguageModel`, such as a provider
60
+ model returned by `openai('gpt-5.6-terra')`, or an async resolver that chooses a
61
+ model for each generation.
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.
66
+
67
+ `experimental-a2/ai/server` is server-only. The isomorphic `experimental-a2/ai` entry point contains the
68
+ contract, reducer, schemas, and pure inputs; it never imports a model provider
69
+ or backend.
70
+
71
+ ## Expose the event stream
72
+
73
+ One HTTP route gives the browser a read and write path. `GET` streams events;
74
+ `POST` accepts optimistic pushes:
75
+
76
+ ```ts app/api/agent-events/route.ts
77
+ import { A2Error } from 'experimental-a2'
78
+ import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
79
+ import { assistantServer } from '@/server/assistant'
80
+
81
+ export async function GET(req: Request): Promise<Response> {
82
+ const { searchParams } = new URL(req.url)
83
+ const sessionId = searchParams.get('sessionId')
84
+ const startAt = Number(searchParams.get('index')) || 0
85
+
86
+ if (!sessionId) {
87
+ return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
88
+ }
89
+
90
+ // here's where you'd do auth, or any other checks
91
+
92
+ return sseResponse(assistantServer.session(sessionId).stream({ startAt }))
93
+ }
94
+
95
+ export async function POST(req: Request): Promise<Response> {
96
+ try {
97
+ const { sessionId, events } = await parsePushBody(req)
98
+
99
+ // here's where you'd do auth, or any other checks
100
+
101
+ const appended = await assistantServer.session(sessionId).append(...events)
102
+ return Response.json(appended)
103
+ } catch (error) {
104
+ return errorResponse(error)
105
+ }
106
+ }
107
+ ```
108
+
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.
112
+
113
+ ## Bind the reducer to React
114
+
115
+ Create the typed provider and hook in a client module:
116
+
117
+ ```tsx app/agent/session.ts
118
+ 'use client'
119
+ import { createClient } from 'experimental-a2/client'
120
+ import { createReact } from 'experimental-a2/react'
121
+ import { assistant } from '@/assistant'
122
+
123
+ export const assistantClient = createClient({
124
+ reducer: assistant.reducer,
125
+ api: '/api/agent-events',
126
+ })
127
+
128
+ export const { SessionProvider, useSession } = createReact({
129
+ client: assistantClient,
130
+ })
131
+ ```
132
+
133
+ Fold the initial state on the server, then let the provider resume the event
134
+ stream from that exact index:
135
+
136
+ ```tsx app/agent/[sessionId]/page.tsx
137
+ import { assistant } from '@/assistant'
138
+ import { assistantServer } from '@/server/assistant'
139
+ import { AgentClient } from './agent-client'
140
+ import { SessionProvider } from '../session'
141
+
142
+ export default async function AgentPage({
143
+ params,
144
+ }: {
145
+ params: Promise<{ sessionId: string }>
146
+ }) {
147
+ const { sessionId } = await params
148
+ const session = assistantServer.session(sessionId)
149
+ const { state, index } = await session.state(assistant.reducer)
150
+ const initialEvents = (await session.history()).filter(
151
+ (event) => event.index <= index,
152
+ )
153
+
154
+ return (
155
+ <SessionProvider
156
+ sessionId={sessionId}
157
+ initialState={state}
158
+ initialIndex={index}
159
+ initialEvents={initialEvents}
160
+ >
161
+ <AgentClient />
162
+ </SessionProvider>
163
+ )
164
+ }
165
+ ```
166
+
167
+ An empty session folds to the initial `AIState`. Its log is created lazily when
168
+ the first message is pushed.
169
+
170
+ Start the session from the previous route, but wait for its durable append
171
+ before navigating. The destination server render then sees the message and its
172
+ events in one complete response instead of racing the `POST`:
173
+
174
+ ```tsx app/agent/new-agent-session.tsx
175
+ 'use client'
176
+ import type { UIMessage } from 'ai'
177
+ import { inputs } from 'experimental-a2/ai'
178
+ import { assistantClient } from './session'
179
+
180
+ export async function openAgent(
181
+ sessionId: string,
182
+ message: UIMessage,
183
+ router: { push(href: string): void },
184
+ ): Promise<void> {
185
+ await assistantClient.session(sessionId).push(
186
+ {
187
+ type: 'ai.session.created',
188
+ payload: { metadata: {} },
189
+ },
190
+ ...inputs.message(message),
191
+ )
192
+ router.push(`/agent/${sessionId}`)
193
+ }
194
+ ```
195
+
196
+ The draft clears immediately while the append is in flight and can be restored
197
+ if it fails. Once navigation begins, SSR already has a durable frontier and
198
+ event history. When the destination provider mounts, it resolves the same live
199
+ session and reconciles that server render without replacing the object. Idle
200
+ session objects expire after five minutes by default; configure `gcTime` on
201
+ `createClient` when a different lifetime fits the app.
202
+
203
+ ## Send messages from the client
204
+
205
+ Build the message inline and pass the resulting events straight to `push()`:
206
+
207
+ ```tsx app/agent/[sessionId]/agent-client.tsx
208
+ 'use client'
209
+ import type { FormEvent } from 'react'
210
+ import { useState } from 'react'
211
+ import { inputs } from 'experimental-a2/ai'
212
+ import { useSession } from '../session'
213
+
214
+ export function AgentClient() {
215
+ const { state, push } = useSession()
216
+ const [draft, setDraft] = useState('')
217
+ const [error, setError] = useState<string | null>(null)
218
+
219
+ const canSend = state.status === 'idle' || state.status === 'failed'
220
+ const lastMessage = state.messages.at(-1)
221
+ const lastText =
222
+ lastMessage?.parts
223
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
224
+ .join('\n') ?? ''
225
+ const showThinking =
226
+ state.status === 'generating' &&
227
+ (lastMessage?.role !== 'assistant' || lastText.length === 0)
228
+
229
+ async function send(event: FormEvent<HTMLFormElement>): Promise<void> {
230
+ event.preventDefault()
231
+ const previousDraft = draft
232
+ const text = previousDraft.trim()
233
+ if (!text || !canSend) return
234
+
235
+ setDraft('')
236
+ setError(null)
237
+
238
+ try {
239
+ await push(
240
+ ...inputs.message({
241
+ id: crypto.randomUUID(),
242
+ role: 'user',
243
+ parts: [{ type: 'text', text }],
244
+ }),
245
+ )
246
+ } catch (cause) {
247
+ setDraft(previousDraft)
248
+ setError(cause instanceof Error ? cause.message : String(cause))
249
+ }
250
+ }
251
+
252
+ return (
253
+ <main>
254
+ <section aria-label="Conversation">
255
+ {state.messages.map((message) => {
256
+ const text = message.parts
257
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
258
+ .join('\n')
259
+ if (!text) return null
260
+
261
+ return (
262
+ <article key={message.id}>
263
+ <strong>{message.role === 'user' ? 'You' : 'Agent'}</strong>
264
+ <p>{text}</p>
265
+ </article>
266
+ )
267
+ })}
268
+
269
+ {showThinking ? (
270
+ <article aria-live="polite">
271
+ <strong>Agent</strong>
272
+ <p>Thinking…</p>
273
+ </article>
274
+ ) : null}
275
+ </section>
276
+
277
+ <form onSubmit={send}>
278
+ <input
279
+ aria-label="Message"
280
+ value={draft}
281
+ onChange={(event) => setDraft(event.currentTarget.value)}
282
+ />
283
+ <button disabled={!draft.trim() || !canSend}>Send</button>
284
+ </form>
285
+
286
+ {error ? <p role="alert">{error}</p> : null}
287
+ </main>
288
+ )
289
+ }
290
+ ```
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.
297
+
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.
302
+
303
+ ## What happens after `push()`
304
+
305
+ One user interaction becomes a durable sequence:
306
+
307
+ ```text
308
+ browser ai.message.created optimistic, then durable
309
+ browser ai.generation.requested optimistic, then durable
310
+ server ai.generation.started
311
+ server ai.generation.progress batched UIMessageChunk[]
312
+ server ai.tool.called when present
313
+ server ai.tool.result when present
314
+ server ai.approval.requested when present
315
+ server ai.generation.completed
316
+ server ai.message.completed
317
+ ```
318
+
319
+ The SSE connection delivers the server events to the same reducer. Progress
320
+ stores every AI SDK chunk once. The reducer and the exported
321
+ `deriveUIMessages()` apply those chunks synchronously to produce the current
322
+ `UIMessage`; cumulative message snapshots are not duplicated in the log.
323
+
324
+ `AIState` exposes `messages`, `status`, `activeGeneration`, `activeProjection`,
325
+ `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.
329
+
330
+ ## The built-in inputs
331
+
332
+ `inputs` contains pure, isomorphic builders for protocol interactions:
333
+
334
+ | Input | Events |
335
+ | --- | --- |
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 |
342
+ | `inputs.interrupt(options)` | interrupts an active response |
343
+
344
+ 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()`.
347
+
348
+ `inputs` deliberately has no session lifecycle methods. The A2 log begins with
349
+ the first append. Apps that use explicit `ai.session.created` or
350
+ `ai.session.closed` lifecycle events push those ordinary contract events
351
+ directly.
352
+
353
+ ## Add tools and approval
354
+
355
+ Pass ordinary AI SDK tools to the server. Approval policy belongs in
356
+ `generation`, beside the other `ToolLoopAgent` settings:
357
+
358
+ ```ts server/with-tools.ts
359
+ import { tool } from 'ai'
360
+ import { z } from 'zod'
361
+ import { createAgentServer } from 'experimental-a2/ai/server'
362
+ import { assistant } from '../assistant'
363
+
364
+ const tools = {
365
+ closeTicket: tool({
366
+ description: 'Close a resolved support ticket',
367
+ inputSchema: z.object({ ticketId: z.string() }),
368
+ execute: async ({ ticketId }, { toolCallId }) => {
369
+ // your side effect; toolCallId makes a stable idempotency key:
370
+ // await closeTicket(ticketId, { idempotencyKey: toolCallId })
371
+ return { ticketId, closed: true }
372
+ },
373
+ }),
374
+ }
375
+
376
+ export const assistantServerWithTools = createAgentServer({
377
+ agent: assistant,
378
+ model: 'openai/gpt-5.6-terra',
379
+ tools,
380
+ generation: {
381
+ toolApproval: {
382
+ closeTicket: 'user-approval',
383
+ },
384
+ },
385
+ })
386
+ ```
387
+
388
+ The AI SDK emits an approval request instead of executing the tool. A2 records
389
+ it as `ai.approval.requested`, and the reducer adds it to
390
+ `state.pendingApprovals`. Respond from the same optimistic client path:
391
+
392
+ ```tsx app/agent/[sessionId]/approval-controls.tsx
393
+ 'use client'
394
+ import { inputs } from 'experimental-a2/ai'
395
+ import { useSession } from '../session'
396
+
397
+ export function ApprovalControls() {
398
+ const { state, push } = useSession()
399
+
400
+ return state.pendingApprovals.map((approval) => (
401
+ <div key={approval.approvalId}>
402
+ <span>Allow tool call {approval.toolCallId}?</span>
403
+ <button
404
+ onClick={() =>
405
+ void push(
406
+ ...inputs.approval({
407
+ messageId: approval.messageId,
408
+ approvalId: approval.approvalId,
409
+ approved: true,
410
+ }),
411
+ )
412
+ }
413
+ >
414
+ Allow
415
+ </button>
416
+ <button
417
+ onClick={() =>
418
+ void push(
419
+ ...inputs.approval({
420
+ messageId: approval.messageId,
421
+ approvalId: approval.approvalId,
422
+ approved: false,
423
+ reason: 'Denied by the user',
424
+ }),
425
+ )
426
+ }
427
+ >
428
+ Deny
429
+ </button>
430
+ </div>
431
+ ))
432
+ }
433
+ ```
434
+
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.
438
+
439
+ Tool activity follows the full AI SDK chunk lifecycle. Input validation errors,
440
+ execution errors, denials, and preliminary and final outputs become durable
441
+ `ai.tool.result` events. Preliminary outputs keep the tool `running`; a final
442
+ output completes it. Each result has its own event id, so a preliminary result
443
+ cannot deduplicate the final one. Dynamic-tool, provider-executed, provider
444
+ metadata, and tool metadata fields are preserved in the projection.
445
+
446
+ `inputs.input()` provides the same durable request/response shape for
447
+ application-defined input. Its value stays in raw history and
448
+ `state.pendingInputs`; resolve instructions dynamically or replace `generate`
449
+ when the value should become model context.
450
+
451
+ ## Interrupt and retry
452
+
453
+ Interrupt the active response from the client:
454
+
455
+ ```tsx app/agent/[sessionId]/stop-button.tsx
456
+ 'use client'
457
+ import { inputs } from 'experimental-a2/ai'
458
+ import { useSession } from '../session'
459
+
460
+ export function StopButton() {
461
+ const { state, push, index } = useSession()
462
+ const active = state.activeGeneration
463
+
464
+ if (!active) return null
465
+
466
+ return (
467
+ <button
468
+ onClick={() =>
469
+ void push(
470
+ ...inputs.interrupt({
471
+ messageId: active.responseMessageId,
472
+ generationId: active.generationId,
473
+ reason: 'Stopped by the user',
474
+ lastSeenIndex: index,
475
+ }),
476
+ )
477
+ }
478
+ >
479
+ Stop
480
+ </button>
481
+ )
482
+ }
483
+ ```
484
+
485
+ The optimistic event updates the UI immediately and reaches A2's cancellation
486
+ channel. `lastSeenIndex` is the exact confirmed log frontier visible when the
487
+ user clicked. The reducer rewinds chunk and tool projections to that frontier,
488
+ keeps the partial response the user actually saw, and turns incomplete visible
489
+ tools into `output-error`. Progress, completion, or failure from that generation
490
+ cannot reactivate it after the interruption.
491
+
492
+ 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.
496
+
497
+ ## Append from the server
498
+
499
+ Browser interactions should use `push()` for instant state. Server-originated
500
+ turns use the same inputs with `append()`:
501
+
502
+ ```ts server/append-message.ts
503
+ import { inputs } from 'experimental-a2/ai'
504
+ import { assistantServer } from './assistant'
505
+
506
+ export async function appendServerMessage(sessionId: string, text: string) {
507
+ await assistantServer.session(sessionId).append(
508
+ ...inputs.message({
509
+ id: crypto.randomUUID(),
510
+ role: 'user',
511
+ parts: [{ type: 'text', text }],
512
+ }),
513
+ )
514
+ }
515
+ ```
516
+
517
+ This path fits webhooks, jobs, and other backend sources. A connected browser
518
+ still receives the events over SSE, but it has no optimistic overlay for an
519
+ interaction that began elsewhere.
520
+
521
+ ## Compact long conversations
522
+
523
+ Compaction is optional application policy. A2 records both the decision and
524
+ replacement messages, so later prompts remain explainable from history:
525
+
526
+ ```ts server/compacted.ts
527
+ import type { UIMessage } from 'ai'
528
+ import { createAgentServer } from 'experimental-a2/ai/server'
529
+ import { assistant } from '../assistant'
530
+
531
+ export const compactedAssistantServer = createAgentServer({
532
+ agent: assistant,
533
+ model: 'openai/gpt-5.6-terra',
534
+ compaction: {
535
+ shouldCompact: ({ messages }) => messages.length > 40,
536
+ compact: async ({ messages }) => {
537
+ const summary = {
538
+ id: crypto.randomUUID(),
539
+ role: 'user',
540
+ parts: [{ type: 'text', text: 'Summary of the earlier conversation.' }],
541
+ } satisfies UIMessage
542
+
543
+ return [summary, ...messages.slice(-10)]
544
+ },
545
+ },
546
+ })
547
+ ```
548
+
549
+ The callback can call another model, create a deterministic summary, or retain
550
+ selected messages. A2 owns only when the result enters the log and how later
551
+ generations consume it.
552
+
553
+ ## Extend the assembly
554
+
555
+ The convenience API is ordinary A2 parts:
556
+
557
+ - `events` and `createEvents()` export the built-in schemas.
558
+ - `createReducer({ contract })` builds the standard `AIState` projection for a
559
+ compatible contract.
560
+ - `agent()` combines the built-in protocol, application events, and reducer.
561
+ - `createHandlers({ agent, ... })` returns the AI handler table.
562
+ - `createAgentServer()` combines those handlers with `createServer()`.
563
+
564
+ Use the lower-level pieces when an application needs more handlers or a
565
+ different server assembly:
566
+
567
+ ```ts server/custom.ts
568
+ import { z } from 'zod'
569
+ import { agent } from 'experimental-a2/ai'
570
+ import { createHandlers } from 'experimental-a2/ai/server'
571
+ import { createServer } from 'experimental-a2/server'
572
+
573
+ const supportAgent = agent({
574
+ name: 'extended-support-agent',
575
+ events: {
576
+ 'ticket.linked': z.object({ ticketId: z.string() }),
577
+ },
578
+ })
579
+
580
+ const aiHandlers = createHandlers({
581
+ agent: supportAgent,
582
+ model: 'openai/gpt-5.6-terra',
583
+ })
584
+
585
+ export const customAssistantServer = createServer({
586
+ contract: supportAgent.contract,
587
+ handlers: {
588
+ ...aiHandlers,
589
+ 'ticket.linked': async ({ event }) => {
590
+ // your side effect; event.id makes a stable idempotency key:
591
+ // await indexTicket(event.payload.ticketId, { idempotencyKey: event.id })
592
+ },
593
+ },
594
+ })
595
+ ```
596
+
597
+ Application events stay fully typed. The standard AI reducer ignores unknown
598
+ events, so another reducer can project application state without forking the AI
599
+ protocol.
600
+
601
+ ### Replace generation, not durability
602
+
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:
606
+
607
+ ```ts server/custom-generation.ts
608
+ import { ToolLoopAgent, createAgentUIStream } from 'ai'
609
+ import { createAgentServer } from 'experimental-a2/ai/server'
610
+ import { assistant } from '../assistant'
611
+
612
+ export const customGenerationServer = createAgentServer({
613
+ agent: assistant,
614
+ model: 'openai/gpt-5.6-terra',
615
+ generate: async ({
616
+ messages,
617
+ model,
618
+ tools,
619
+ instructions,
620
+ generation,
621
+ generationId,
622
+ responseMessageId,
623
+ signal,
624
+ }) => {
625
+ const sdkAgent = new ToolLoopAgent<never, typeof tools>({
626
+ ...generation,
627
+ id: generationId,
628
+ model,
629
+ tools,
630
+ ...(instructions === undefined ? {} : { instructions }),
631
+ })
632
+
633
+ return createAgentUIStream({
634
+ agent: sdkAgent,
635
+ uiMessages: messages,
636
+ generateMessageId: () => responseMessageId,
637
+ abortSignal: signal,
638
+ })
639
+ },
640
+ })
641
+ ```
642
+
643
+ `generate` receives compacted messages, resolved model and instructions, tools,
644
+ 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.
648
+
649
+ ## Delivery semantics
650
+
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.
656
+
657
+ Configure [production recovery](/guides/production) exactly as for any other A2
658
+ server. Recovery wakes an interrupted generation after the original serverless
659
+ invocation disappears.