@tanstack/ai 0.46.0 → 0.47.2

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 (66) hide show
  1. package/dist/esm/activities/chat/index.d.ts +36 -11
  2. package/dist/esm/activities/chat/index.js +440 -79
  3. package/dist/esm/activities/chat/index.js.map +1 -1
  4. package/dist/esm/activities/chat/messages.d.ts +1 -0
  5. package/dist/esm/activities/chat/messages.js +12 -7
  6. package/dist/esm/activities/chat/messages.js.map +1 -1
  7. package/dist/esm/activities/chat/middleware/builder.d.ts +7 -2
  8. package/dist/esm/activities/chat/middleware/builder.js.map +1 -1
  9. package/dist/esm/activities/chat/middleware/compose.d.ts +10 -3
  10. package/dist/esm/activities/chat/middleware/compose.js +55 -0
  11. package/dist/esm/activities/chat/middleware/compose.js.map +1 -1
  12. package/dist/esm/activities/chat/middleware/define.d.ts +6 -3
  13. package/dist/esm/activities/chat/middleware/define.js.map +1 -1
  14. package/dist/esm/activities/chat/middleware/generic-interrupts.d.ts +13 -0
  15. package/dist/esm/activities/chat/middleware/generic-interrupts.js +8 -0
  16. package/dist/esm/activities/chat/middleware/generic-interrupts.js.map +1 -0
  17. package/dist/esm/activities/chat/middleware/index.d.ts +4 -1
  18. package/dist/esm/activities/chat/middleware/types.d.ts +54 -3
  19. package/dist/esm/activities/chat/middleware/types.js +16 -0
  20. package/dist/esm/activities/chat/middleware/types.js.map +1 -0
  21. package/dist/esm/activities/chat/stream/processor.js +18 -5
  22. package/dist/esm/activities/chat/stream/processor.js.map +1 -1
  23. package/dist/esm/adapter-internals.d.ts +6 -0
  24. package/dist/esm/adapter-internals.js +4 -1
  25. package/dist/esm/client.d.ts +4 -0
  26. package/dist/esm/client.js +3 -1
  27. package/dist/esm/client.js.map +1 -1
  28. package/dist/esm/generic-interrupt-continuation.d.ts +45 -0
  29. package/dist/esm/generic-interrupt-continuation.js +80 -0
  30. package/dist/esm/generic-interrupt-continuation.js.map +1 -0
  31. package/dist/esm/index.d.ts +6 -1
  32. package/dist/esm/index.js +4 -1
  33. package/dist/esm/interrupt-definition.d.ts +113 -0
  34. package/dist/esm/interrupt-definition.js +169 -0
  35. package/dist/esm/interrupt-definition.js.map +1 -0
  36. package/dist/esm/interrupt-resume.d.ts +3 -0
  37. package/dist/esm/interrupt-resume.js +77 -16
  38. package/dist/esm/interrupt-resume.js.map +1 -1
  39. package/dist/esm/interrupts.d.ts +12 -3
  40. package/dist/esm/interrupts.js.map +1 -1
  41. package/dist/esm/types.d.ts +11 -3
  42. package/dist/esm/utilities/chat-params.js +10 -1
  43. package/dist/esm/utilities/chat-params.js.map +1 -1
  44. package/package.json +3 -3
  45. package/skills/ai-core/media-generation/SKILL.md +4 -1
  46. package/skills/ai-core/middleware/SKILL.md +53 -44
  47. package/skills/ai-core/structured-outputs/SKILL.md +59 -55
  48. package/skills/ai-core/tool-calling/SKILL.md +54 -1
  49. package/src/activities/chat/index.ts +1030 -211
  50. package/src/activities/chat/messages.ts +11 -3
  51. package/src/activities/chat/middleware/builder.ts +29 -4
  52. package/src/activities/chat/middleware/compose.ts +95 -5
  53. package/src/activities/chat/middleware/define.ts +13 -3
  54. package/src/activities/chat/middleware/generic-interrupts.ts +26 -0
  55. package/src/activities/chat/middleware/index.ts +15 -0
  56. package/src/activities/chat/middleware/types.ts +127 -2
  57. package/src/activities/chat/stream/processor.ts +21 -0
  58. package/src/adapter-internals.ts +20 -0
  59. package/src/client.ts +20 -0
  60. package/src/generic-interrupt-continuation.ts +162 -0
  61. package/src/index.ts +34 -0
  62. package/src/interrupt-definition.ts +581 -0
  63. package/src/interrupt-resume.ts +156 -25
  64. package/src/interrupts.ts +13 -3
  65. package/src/types.ts +11 -3
  66. package/src/utilities/chat-params.ts +16 -3
@@ -52,21 +52,21 @@ Every hook receives a `ChatMiddlewareContext` as its first argument, which provi
52
52
  `requestId`, `streamId`, `phase`, `iteration`, `chunkIndex`, `model`, `provider`,
53
53
  `signal`, `abort()`, `defer()`, and more.
54
54
 
55
- | Hook | When | Second Argument |
56
- | -------------------------- | -------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
57
- | `onConfig` | Once at startup (`init`) + once per iteration (`beforeModel`) + once at structured-output boundary | `ChatMiddlewareConfig` (return partial to merge) |
58
- | `onStructuredOutputConfig` | Once at the structured-output boundary (only when `chat({ outputSchema })`) | `StructuredOutputMiddlewareConfig` (return partial) |
59
- | `onStart` | Once after initial `onConfig` | none |
60
- | `onIteration` | Start of each agent loop iteration | `IterationInfo` |
61
- | `onShouldContinue` | Whether to start another agent-loop iteration (AND with strategy; `false` stops) | `AgentLoopState` |
62
- | `onChunk` | Every streamed chunk | `StreamChunk` (return void/chunk/chunk[]/null) |
63
- | `onBeforeToolCall` | Before each tool executes | `ToolCallHookContext` (return decision or void) |
64
- | `onAfterToolCall` | After each tool executes | `AfterToolCallInfo` |
65
- | `onToolPhaseComplete` | After all tool calls in an iteration | `ToolPhaseCompleteInfo` |
66
- | `onUsage` | When `RUN_FINISHED` includes usage data | `UsageInfo` |
67
- | `onFinish` | Run completed normally | `FinishInfo` |
68
- | `onAbort` | Run was aborted | `AbortInfo` |
69
- | `onError` | Unhandled error occurred | `ErrorInfo` |
55
+ | Hook | When | Second Argument |
56
+ | -------------------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
57
+ | `onConfig` | Once at startup (`init`) + once per iteration (`beforeModel`) + once at a separate-finalization boundary | `ChatMiddlewareConfig` (return partial to merge) |
58
+ | `onStructuredOutputConfig` | Once at the separate-finalization boundary | `StructuredOutputMiddlewareConfig` (return partial) |
59
+ | `onStart` | Once after initial `onConfig` | none |
60
+ | `onIteration` | Start of each agent loop iteration | `IterationInfo` |
61
+ | `onShouldContinue` | Whether to start another agent-loop iteration (AND with strategy; `false` stops) | `AgentLoopState` |
62
+ | `onChunk` | Every streamed chunk | `StreamChunk` (return void/chunk/chunk[]/null) |
63
+ | `onBeforeToolCall` | Before each tool executes | `ToolCallHookContext` (return decision or void) |
64
+ | `onAfterToolCall` | After each tool executes | `AfterToolCallInfo` |
65
+ | `onToolPhaseComplete` | After all tool calls in an iteration | `ToolPhaseCompleteInfo` |
66
+ | `onUsage` | When `RUN_FINISHED` includes usage data | `UsageInfo` |
67
+ | `onFinish` | Run completed normally | `FinishInfo` |
68
+ | `onAbort` | Run was aborted | `AbortInfo` |
69
+ | `onError` | Unhandled error occurred | `ErrorInfo` |
70
70
 
71
71
  Terminal hooks (`onFinish`, `onAbort`, `onError`) are **mutually exclusive** -- exactly
72
72
  one fires per `chat()` invocation.
@@ -82,31 +82,39 @@ one fires per `chat()` invocation.
82
82
 
83
83
  `ctx.phase` is one of:
84
84
 
85
- | Phase | When |
86
- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
- | `'init'` | Initial setup (before the first `onConfig` snapshot is built). |
88
- | `'beforeModel'` | Right before each agent-loop adapter call (`onConfig` re-fires here). |
89
- | `'modelStream'` | During model streaming chunks within the agent loop. |
90
- | `'beforeTools'` | Before tool execution phase. |
91
- | `'afterTools'` | After tool execution phase. |
92
- | `'structuredOutput'` | During the final structured-output adapter call (set for all chunks from `adapter.structuredOutputStream` or the synthesized fallback). Triggered only when `chat({ outputSchema })` is invoked; one phase transition per `chat()` invocation. |
85
+ | Phase | When |
86
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `'init'` | Initial setup (before the first `onConfig` snapshot is built). |
88
+ | `'beforeModel'` | Right before each agent-loop adapter call (`onConfig` re-fires here). |
89
+ | `'modelStream'` | During model streaming chunks within the agent loop. |
90
+ | `'beforeTools'` | Before tool execution phase. |
91
+ | `'afterTools'` | After tool execution phase. |
92
+ | `'structuredOutput'` | During the separate-finalization adapter call (set for all chunks from `adapter.structuredOutputStream` or the synthesized fallback). Does not occur for native-combined output. |
93
93
 
94
- **Structured-output lifecycle rules** (when `chat({ outputSchema })` is used):
94
+ **Separate-finalization path** (adapters without native-combined support):
95
95
 
96
96
  - `onStructuredOutputConfig` fires **before** `onConfig` at the structured-output boundary.
97
97
  - `onConfig` re-fires at the same boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config (minus `outputSchema`).
98
98
  - `onChunk` and `onUsage` fire for every chunk and usage event emitted by the structured-output call, with `ctx.phase === 'structuredOutput'`.
99
99
  - `onIteration` does **not** fire for finalization — it is agent-loop-only.
100
- - `onFinish` fires once at the end of the whole `chat()` invocation, **after** the structured-output finalization completes (not after the agent loop). Terminal-hook exclusivity still holds (one of `onFinish` / `onAbort` / `onError`).
101
100
  - **Terminal `info` and structured-output:** `info.usage` / `info.finishReason` / `info.content` reflect the **agent loop's** terminal state, NOT the finalization step. Finalization state is intentionally segregated to keep agent-loop semantics clean. For a tools-less `chat({ outputSchema })` run, `info.usage` is `undefined` and `info.finishReason` is `null` (no agent-loop iteration produced `RUN_FINISHED`). To capture finalization tokens, use `onUsage` — it fires for both agent-loop iterations and the final call. For the structured-output result itself, observe the `structured-output.complete` CUSTOM event in `onChunk`.
102
101
 
102
+ **Native-combined output:**
103
+
104
+ - The schema-constrained JSON is produced by a normal agent-loop iteration. `onStructuredOutputConfig` does not fire, `ctx.phase` remains `'modelStream'`, and `onIteration` fires for that iteration.
105
+ - `info.content` includes the structured JSON because it is agent-loop text. Middleware observes the `structured-output.complete` event in `onChunk` during the same phase.
106
+
107
+ **Both paths:**
108
+
109
+ - On successful completion, `onFinish` fires once after the structured result completes. Terminal-hook exclusivity still holds.
110
+ - By `onFinish`, `ctx.messages` includes the completed terminal assistant messages. Native-combined output keeps the structured result on its terminal assistant message. The separate-finalization path can preserve the agent loop's plain-text message followed by a distinct structured-output message.
111
+
103
112
  ## onStructuredOutputConfig
104
113
 
105
- A dedicated config hook that fires **only** at the structured-output boundary
106
- (when `chat({ outputSchema })` is invoked). Use it to transform the JSON Schema
107
- sent to the provider (inject `$defs`, strip vendor-incompatible keywords) or to
108
- apply structured-output-specific config changes that should not affect the
109
- agent-loop adapter calls.
114
+ A dedicated config hook that fires **only** at the separate-finalization
115
+ boundary. Use it to transform the JSON Schema sent to the provider (inject
116
+ `$defs`, strip vendor-incompatible keywords) or to apply structured-output-
117
+ specific config changes that should not affect the agent-loop adapter calls.
110
118
 
111
119
  **Signature:**
112
120
 
@@ -259,13 +267,14 @@ const toolGuard: ChatMiddleware = {
259
267
 
260
268
  ### Pattern 3: Structured-Output Middleware
261
269
 
262
- When `chat({ outputSchema })` is used, the final structured-output adapter call
263
- now flows through the same middleware chain as the agent loop (with
264
- `ctx.phase === 'structuredOutput'`). Before this change, the final call bypassed
265
- middleware entirely — `onChunk`, `onUsage`, `onConfig`, and terminal hooks did
266
- not see it.
270
+ On the separate-finalization path, the final structured-output adapter call
271
+ flows through the same middleware chain as the agent loop with
272
+ `ctx.phase === 'structuredOutput'`. Native-combined output has no separate
273
+ provider call: middleware observes its chunks during `modelStream`, and
274
+ `onStructuredOutputConfig` does not fire. Middleware cannot transform the
275
+ native-combined schema.
267
276
 
268
- **Example A — Observability (tracing every chunk, including finalization):**
277
+ **Example A — Observability (tracing every chunk, including separate finalization):**
269
278
 
270
279
  ```typescript
271
280
  import type { ChatMiddleware } from '@tanstack/ai'
@@ -278,10 +287,10 @@ const tracing: ChatMiddleware = {
278
287
  }
279
288
  ```
280
289
 
281
- This middleware now observes every chunk from the final structured-output call,
282
- attributed to `ctx.phase === 'structuredOutput'`. Before the fix, the final
283
- adapter call bypassed middleware entirely — `tracing` would only see agent-loop
284
- chunks.
290
+ On the separate-finalization path, this middleware observes every chunk from
291
+ the final structured-output call with `ctx.phase === 'structuredOutput'`. On
292
+ the native-combined path, it observes the structured stream with
293
+ `ctx.phase === 'modelStream'`.
285
294
 
286
295
  **Example B — Schema rewriting (inject shared `$defs`):**
287
296
 
@@ -298,9 +307,9 @@ const injectDefs: ChatMiddleware = {
298
307
  }
299
308
  ```
300
309
 
301
- `onStructuredOutputConfig` is the right hook here because it has direct access
302
- to `config.outputSchema` and runs only on the structured-output boundary —
303
- schema rewrites do not leak into the agent-loop adapter calls.
310
+ `onStructuredOutputConfig` is the right hook here on the separate-finalization
311
+ path because it has direct access to `config.outputSchema`. Native-combined
312
+ schema transformation is not exposed through middleware.
304
313
 
305
314
  ### Pattern 4: Multiple Middleware Composition
306
315
 
@@ -779,6 +788,6 @@ Source: docs/advanced/middleware.md, `packages/ai/src/activities/chat/middleware
779
788
  ## Cross-References
780
789
 
781
790
  - See also: **ai-core/chat-experience/SKILL.md** -- Middleware hooks into the chat lifecycle
782
- - See also: **ai-core/structured-outputs/SKILL.md** -- Middleware now wraps the final structured-output call; use `onStructuredOutputConfig` for JSON-Schema transforms
791
+ - See also: **ai-core/structured-outputs/SKILL.md** -- Separate finalization uses `onStructuredOutputConfig` for JSON-Schema transforms; native-combined schema transformation is not exposed through middleware
783
792
  - See also: **ai-core/ag-ui-protocol/SKILL.md** -- Reading the `sandbox.file` / `sandbox.file.diff` `CUSTOM` chunks the sandbox runtime emits alongside these `sandbox` hooks, via `ChatStream`'s typed `KnownCustomEvent` narrowing
784
793
  - See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Full persistence suite (`withPersistence`, client storage, store contracts, adapter recipes, locks). This file only sketches server `withPersistence`.
@@ -5,12 +5,11 @@ description: >
5
5
  and useChat(). Supports Zod, ArkType, and Valibot schemas. The adapter
6
6
  handles provider-specific strategies transparently — never configure
7
7
  structured output at the provider level. Pass stream:true alongside
8
- outputSchema for incremental JSON deltas + a terminal validated object
9
- via the `structured-output.complete` event. Every assistant turn in
10
- useChat carries its own typed `StructuredOutputPart` on
11
- `messages[i].parts`, so multi-turn structured chats preserve history
12
- automatically — partial/final derive from the latest assistant turn's
13
- part. convertSchemaToJsonSchema() for manual schema conversion.
8
+ outputSchema for incremental JSON deltas + a completed typed object
9
+ via the `structured-output.complete` event. Each successfully completed
10
+ structured-output run adds a typed `StructuredOutputPart` to message
11
+ history. partial/final derive from the most recent structured-output part
12
+ after the latest user message. convertSchemaToJsonSchema() for manual schema conversion.
14
13
  type: sub-skill
15
14
  library: tanstack-ai
16
15
  library_version: '0.42.0'
@@ -146,7 +145,7 @@ console.log(company.financials?.revenue)
146
145
 
147
146
  ### Pattern 3: Direct stream iteration
148
147
 
149
- Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a terminal validated object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
148
+ Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
150
149
 
151
150
  ```typescript
152
151
  import { chat } from '@tanstack/ai'
@@ -170,8 +169,8 @@ const stream = chat({
170
169
 
171
170
  for await (const chunk of stream) {
172
171
  if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
173
- // Terminal event. `chunk.value.object` is fully validated and typed
174
- // against the schema you passed in — no helper or cast required.
172
+ // Terminal event. `chunk.value.object` is complete and typed against the
173
+ // schema you passed in. Validate it in the consumer when required.
175
174
  chunk.value.object.name // string
176
175
  chunk.value.object.age // number
177
176
  chunk.value.reasoning // string | undefined (thinking models only)
@@ -183,24 +182,24 @@ The terminal event is a `CUSTOM` chunk: `{ type: 'CUSTOM', name: 'structured-out
183
182
 
184
183
  **Adapter coverage for streaming:**
185
184
 
186
- | Adapter | `outputSchema` + `stream: true` |
187
- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
188
- | `@tanstack/ai-openai` (Responses + Chat Completions) | **Native combined mode (#605)** — schema wired into the regular `chatStream` call alongside `tools`; engine harvests JSON, no finalization round-trip |
189
- | `@tanstack/ai-anthropic` (Claude 4.5+ only) | **Native combined mode (#605)** — `output_config.format` + `tools` in one beta Messages call. Older Claude models fall back |
190
- | `@tanstack/ai-gemini` (Gemini 3.x only) | **Native combined mode (#605)** — `responseSchema` + `tools` in one `generateContentStream`. Gemini 2.x falls back |
191
- | `@tanstack/ai-grok` (Grok 4 family only) | **Native combined mode (#605)** — `response_format: json_schema` + `tools`. Grok 2 / 3 fall back |
192
- | `@tanstack/ai-openrouter` | Native single-request stream (legacy `structuredOutputStream` path; per-call combined-mode lookup is a follow-up) |
193
- | `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) |
194
- | `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API |
195
- | `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` |
196
- | `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
197
- | `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
198
- | `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |
199
- | `@tanstack/ai-grok-build` | Combined + event source — prompt-and-parse (ACP and streaming-json). Read `useChat().final` or the `structured-output` part. See Pattern 6. |
200
- | `@tanstack/ai-acp` (`acpCompatible`) | Combined + event source — prompt-and-parse. Read `useChat().final` or the `structured-output` part. See Pattern 6. |
201
- | All other adapters (ollama, older Claude, Gemini 2.x, Grok 2/3) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
202
-
203
- **Native combined mode vs fallback** is signaled by the adapter's
185
+ | Adapter | `outputSchema` + `stream: true` |
186
+ | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
187
+ | `@tanstack/ai-openai` (Responses + Chat Completions) | **Native combined mode (#605)** — schema wired into the regular `chatStream` call alongside `tools`; engine harvests JSON, no finalization round-trip |
188
+ | `@tanstack/ai-anthropic` (Claude 4.5+ only) | **Native combined mode (#605)** — `output_config.format` + `tools` in one beta Messages call. Older Claude models fall back |
189
+ | `@tanstack/ai-gemini` (Gemini 3.x only) | **Native combined mode (#605)** — `responseSchema` + `tools` in one `generateContentStream`. Gemini 2.x falls back |
190
+ | `@tanstack/ai-grok` | **Native combined mode (#605)** — OpenAI Responses `text.format` + `tools` for grok-4.6, grok-4.5, grok-4.3, and grok-build-0.1 |
191
+ | `@tanstack/ai-openrouter` | Native single-request stream (legacy `structuredOutputStream` path; per-call combined-mode lookup is a follow-up) |
192
+ | `@tanstack/ai-groq` | Legacy `structuredOutputStream` only (no tools — Groq's API rejects schema + tools + stream) |
193
+ | `@tanstack/ai-bedrock` | Separate native `structuredOutputStream` finalization through Converse or an OpenAI-compatible API |
194
+ | `@tanstack/ai-byteplus` | Native combined mode on supported models; unsupported models emit `RUN_ERROR` |
195
+ | `@tanstack/ai-claude-code` | Combined + event source — `--json-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
196
+ | `@tanstack/ai-codex` | Combined + event source — `--output-schema` on the same harness turn. Read `useChat().final`. See Pattern 6. |
197
+ | `@tanstack/ai-opencode` | Combined + event source — prompt-and-parse. Read `useChat().final`. See Pattern 6. |
198
+ | `@tanstack/ai-grok-build` | Combined + event source — prompt-and-parse (ACP and streaming-json). Read `useChat().final` or the `structured-output` part. See Pattern 6. |
199
+ | `@tanstack/ai-acp` (`acpCompatible`) | Combined + event source — prompt-and-parse. Read `useChat().final` or the `structured-output` part. See Pattern 6. |
200
+ | All other adapters (ollama, older Claude, Gemini 2.x) | Fallback: runs non-streaming `structuredOutput`, emits one `structured-output.complete` event |
201
+
202
+ **Native-combined output vs separate finalization** is signaled by the adapter's
204
203
  optional `supportsCombinedToolsAndSchema(modelOptions)` method. When
205
204
  it returns `true`, the engine wires the JSON Schema into the regular
206
205
  `chatStream` call and harvests the final-turn text — middleware sees
@@ -214,7 +213,7 @@ Consumer code is identical across providers — always read the final object off
214
213
 
215
214
  ### Pattern 4: useChat with outputSchema (progressive UI)
216
215
 
217
- Pass `outputSchema` to `useChat` and you get a `partial` field that fills in as JSON streams in, plus a `final` field that snaps to the validated object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
216
+ Pass `outputSchema` to `useChat` and you get a `partial` field that fills in as JSON streams in, plus a `final` field that snaps to the completed typed object on the terminal event. No `onChunk` ceremony, no manual JSON accumulation, no `parsePartialJSON` calls.
218
217
 
219
218
  **Server** (same as Pattern 3, just behind an SSE endpoint):
220
219
 
@@ -272,7 +271,7 @@ function PersonExtractor() {
272
271
  <p>Name: {partial.name ?? '…'}</p>
273
272
  <p>Age: {partial.age ?? '…'}</p>
274
273
  <p>Email: {partial.email ?? '…'}</p>
275
- {final && <pre>Validated: {JSON.stringify(final, null, 2)}</pre>}
274
+ {final && <pre>Completed: {JSON.stringify(final, null, 2)}</pre>}
276
275
  </div>
277
276
  )
278
277
  }
@@ -280,12 +279,12 @@ function PersonExtractor() {
280
279
 
281
280
  - `partial` is `DeepPartial<z.infer<typeof PersonSchema>>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas.
282
281
  - `final` is `z.infer<typeof PersonSchema> | null` — populated when `structured-output.complete` arrives.
283
- - `outputSchema` is for client-side type inference only. **Validation runs on the server** against the schema you pass to `chat({ outputSchema })` there.
282
+ - `outputSchema` in `useChat` is for client-side type inference. The streaming server path does not run Standard Schema validation; validate the completed object in the consumer when required.
284
283
  - Same shape works for non-streaming adapters: the fallback path emits one whole-JSON `TEXT_MESSAGE_CONTENT` then the terminal event, so `partial` populates and `final` snaps in the same render tick — same consumer code as the native-streaming providers, just without an intermediate field-by-field reveal.
285
284
 
286
285
  ### Pattern 5: Multi-turn structured chat
287
286
 
288
- Every assistant turn produced by `useChat({ outputSchema })` carries its own typed `StructuredOutputPart` on `messages[i].parts`. Old turns stay renderable; new turns produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
287
+ Each successfully completed structured-output run adds a typed `StructuredOutputPart` to an assistant message in `messages`. Old responses stay renderable; new completed runs produce new parts; history is preserved without manual state plumbing. This is what makes the recipe-builder shape ("now make it vegan") work.
289
288
 
290
289
  ```tsx
291
290
  import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
@@ -345,10 +344,10 @@ function RecipeCard({ part }: { part: RecipePart }) {
345
344
 
346
345
  Key behaviors:
347
346
 
348
- - **Per-turn parts.** Each `sendMessage()` produces a new assistant message with its own `StructuredOutputPart`. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
347
+ - **Per-turn parts.** Each successfully completed structured-output run adds a structured-output assistant message with its own `StructuredOutputPart`. The separate-finalization path can also produce a plain-text assistant message before it. The previous turn's part is untouched — `messages.map(...)` renders the whole history.
349
348
  - **Typed by schema.** `messages[i].parts.find(p => p.type === 'structured-output').data` is typed as `Recipe` (no cast, no `unknown`). Works because `useChat<TSchema>` threads `InferSchemaType<TSchema>` down through `UIMessage<TTools, TData>` → `MessagePart<TTools, TData>` → `StructuredOutputPart<TData>`. **In `@tanstack/ai` core** the message types are single-generic (`UIMessage<TData>`); the tools generic lives in `@tanstack/ai-client` and the framework hook packages — import from your framework package or `ai-client`, not from `@tanstack/ai`.
350
- - **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest assistant message's part (the one after the most recent user message). Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new assistant turn exists yet.
351
- - **Round-trip preserves history.** When the client sends turn N+1, each prior assistant turn's `structured-output` part is serialized back as `{ role: 'assistant', content: <part.raw> }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip.
349
+ - **`partial` / `final` are derived.** The hook-level `partial` and `final` are NOT singleton state — they're derived from the latest structured-output part after the most recent user message. Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` because no new structured-output part exists yet.
350
+ - **Round-trip preserves history.** Completed structured-output parts remain on their UI messages and are mirrored into provider-facing assistant content using `part.raw`. Streaming and errored parts remain UI state but are excluded from model input.
352
351
 
353
352
  ### Pattern 6: Harness adapters (Claude Code, Codex, OpenCode, Grok Build, ACP)
354
353
 
@@ -410,6 +409,7 @@ final?.name
410
409
  - `partial` stays empty until `structured-output.complete`.
411
410
  - Client tools and `needsApproval` fail fast. The harness cannot pause for a browser round-trip.
412
411
  - Render live work from `messages[].parts` (`thinking`, `tool-call`, `text`, `structured-output`). `final` is only the latest turn.
412
+ - `withPersistence` stores the structured-output part. Distinct event ids become two assistant messages. A reused text id stays on one message. Hydrate with `reconstructChat`.
413
413
  - See [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md).
414
414
 
415
415
  ## Common Mistakes
@@ -448,9 +448,9 @@ Source: PR #577 — structured-output became a typed UIMessage part.
448
448
 
449
449
  ### HIGH: Treating `partial` / `final` as sticky state across turns
450
450
 
451
- `partial` and `final` are **derived from the latest assistant message's `structured-output` part**, not a sticky hook-level slot. In a multi-turn chat:
451
+ `partial` and `final` are **derived from the most recent structured-output part after the latest user message**, not a sticky hook-level slot. In a multi-turn chat:
452
452
 
453
- - Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no assistant message after the latest user yet).
453
+ - Between `sendMessage()` and the first chunk, `partial` reads `{}` and `final` reads `null` (no structured-output part after the latest user message yet).
454
454
  - Once the latest turn completes, `partial === final`. Earlier turns' data is NOT in `partial` / `final` — it lives on the prior assistant messages' parts.
455
455
 
456
456
  To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
@@ -459,7 +459,7 @@ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `fi
459
459
  // WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
460
460
  {final && <RecipeCard recipe={final} />}
461
461
 
462
- // CORRECT for history — walk messages, render every assistant's structured-output part
462
+ // CORRECT for history — walk messages, render each structured-output part
463
463
  {messages.map((m) =>
464
464
  m.role === 'assistant'
465
465
  ? m.parts.find((p) => p.type === 'structured-output')
@@ -469,11 +469,11 @@ To render history, walk `messages` directly (see Pattern 5). Use `partial` / `fi
469
469
  )}
470
470
  ```
471
471
 
472
- Source: PR #577 — partial/final derive from the latest assistant turn's part.
472
+ Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
473
473
 
474
474
  ### HIGH: Parsing streaming JSON deltas yourself
475
475
 
476
- When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Always read the validated object from the terminal `structured-output.complete` event. Validation runs once, on the complete payload.
476
+ When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
477
477
 
478
478
  ```typescript
479
479
  // WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
@@ -486,12 +486,12 @@ for await (const chunk of stream) {
486
486
  // CORRECT -- trust the terminal event
487
487
  for await (const chunk of stream) {
488
488
  if (chunk.type === 'CUSTOM' && chunk.name === 'structured-output.complete') {
489
- const result = chunk.value.object // ✅ typed and validated
489
+ const result = chunk.value.object // ✅ complete and typed
490
490
  }
491
491
  }
492
492
  ```
493
493
 
494
- If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time — but do NOT treat the result as schema-validated; only the terminal event is. In `useChat`, this is already done for you (`partial` field on Pattern 4).
494
+ If you need progressive parsed state in a non-React environment, use a partial-JSON parser on the accumulated raw string at render time. Neither that partial state nor the terminal streaming event is Standard Schema validated. In `useChat`, progressive parsing is already done for you through the `partial` field from Pattern 4.
495
495
 
496
496
  Source: maintainer interview
497
497
 
@@ -528,7 +528,7 @@ of using the schema validation library already in the project (Zod, ArkType,
528
528
  Valibot). Always check what the project uses and match it.
529
529
 
530
530
  ```typescript
531
- // WRONG -- raw object, no runtime validation, no type inference
531
+ // WRONG -- raw schema object, no schema-library type inference
532
532
  chat({
533
533
  adapter,
534
534
  messages,
@@ -556,24 +556,28 @@ chat({
556
556
  })
557
557
  ```
558
558
 
559
- Using the project's schema library gives you runtime validation, TypeScript
560
- type inference on the result, and correct JSON Schema conversion automatically.
561
- Check `package.json` for `zod`, `arktype`, or `valibot` and use whichever is
562
- already installed.
559
+ Using the project's schema library gives you TypeScript type inference and
560
+ correct JSON Schema conversion automatically. The non-streaming
561
+ `await chat({ outputSchema })` path also runs Standard Schema validation; the
562
+ streaming path leaves validation to the consumer. Check `package.json` for
563
+ `zod`, `arktype`, or `valibot` and use whichever is already installed.
563
564
 
564
565
  Source: maintainer interview
565
566
 
566
567
  ## Middleware coverage
567
568
 
568
- The final structured-output adapter call runs through the same middleware
569
- pipeline as the agent loop. `onChunk` observes chunks attributed to
570
- `ctx.phase === 'structuredOutput'`; `onUsage` fires for the final call's
571
- tokens; `onFinish` fires once at the end of the whole `chat()` invocation,
572
- after the structured-output result is available.
569
+ On the separate-finalization path, the final structured-output adapter call
570
+ runs through the middleware pipeline with
571
+ `ctx.phase === 'structuredOutput'`. Use `onStructuredOutputConfig` to transform
572
+ the JSON Schema or finalization config before that provider call.
573
573
 
574
- For schema-aware middleware (e.g., transforming the JSON Schema before the
575
- provider call, stripping system prompts), use the dedicated
576
- `onStructuredOutputConfig` hook. See [middleware skill](../middleware/SKILL.md).
574
+ Native-combined output stays in the regular agent loop. Its chunks use
575
+ `ctx.phase === 'modelStream'`, and `onStructuredOutputConfig` does not fire.
576
+
577
+ On both paths, `onChunk` observes the `structured-output.complete` event,
578
+ `onUsage` observes usage from the provider calls that ran, and `onFinish` fires
579
+ once after the structured-output result is available. See
580
+ [middleware skill](../middleware/SKILL.md).
577
581
 
578
582
  ## Cross-References
579
583
 
@@ -581,4 +585,4 @@ provider call, stripping system prompts), use the dedicated
581
585
  - See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
582
586
  - See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md).
583
587
  - See also: [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md) — dedicated harness adapters and `useChat().final`.
584
- - See also: **ai-core/middleware/SKILL.md** — `onStructuredOutputConfig` hook and the `structuredOutput` phase for observing/transforming the final structured-output call.
588
+ - See also: **ai-core/middleware/SKILL.md** — separate-finalization `onStructuredOutputConfig` / `structuredOutput` behavior and native-combined `modelStream` behavior.
@@ -4,7 +4,8 @@ description: >
4
4
  Isomorphic tool system: toolDefinition() with Zod schemas,
5
5
  .server() and .client() implementations, passing tools to both
6
6
  chat() on server and useChat/clientTools on client, tool approval
7
- flows with needsApproval and bound interrupts (resolveInterrupt), lazy tool
7
+ flows with needsApproval and bound interrupts (resolveInterrupt), generic
8
+ middleware interrupts with defineInterrupt(), lazy tool
8
9
  discovery with lazy:true, rendering ToolCallPart and ToolResultPart
9
10
  in UI.
10
11
  type: sub-skill
@@ -133,6 +134,58 @@ function ChatPage() {
133
134
 
134
135
  ## Core Patterns
135
136
 
137
+ ### Generic middleware interrupts
138
+
139
+ Use `defineInterrupt()` when middleware needs typed data from the client. This
140
+ does not replace `needsApproval`. Tool approval asks whether a tool can run.
141
+ Generic interrupts ask for application data at a chat lifecycle boundary.
142
+
143
+ Define the interrupt once. Register it with both `chat({ interrupts })` and
144
+ `useChat({ interrupts })`. Emit it only from `onInterruptBoundary`, then read
145
+ the typed result in `onInterruptResolution`.
146
+
147
+ ```typescript
148
+ import { defineInterrupt, type ChatMiddleware } from '@tanstack/ai'
149
+ import { z } from 'zod'
150
+
151
+ const reviewPlan = defineInterrupt({
152
+ id: 'review-plan',
153
+ payloadSchema: z.object({ title: z.string() }),
154
+ responseSchema: z.object({ approved: z.boolean() }),
155
+ })
156
+
157
+ const reviewMiddleware: ChatMiddleware<unknown, typeof reviewPlan> = {
158
+ onInterruptBoundary(ctx) {
159
+ if (ctx.phase !== 'beforeTools') return
160
+ return {
161
+ interrupts: [
162
+ reviewPlan.interrupt({
163
+ key: 'release-plan',
164
+ reason: 'review-required',
165
+ message: 'Approve this plan?',
166
+ payload: { title: 'Release plan' },
167
+ }),
168
+ ],
169
+ }
170
+ },
171
+ onInterruptResolution(_ctx, resumedInterrupts) {
172
+ for (const result of resumedInterrupts.for(reviewPlan)) {
173
+ if (result.status === 'resolved' && !result.response.approved) {
174
+ return { toolResume: 'stop' }
175
+ }
176
+ }
177
+ },
178
+ }
179
+ ```
180
+
181
+ Several middleware can request generic interrupts at one boundary. They share
182
+ one AG-UI interrupt batch with tool approvals. A continuation starts only after
183
+ the client resolves or cancels every bound item. `stop` is more restrictive than
184
+ `cancel`, which is more restrictive than `continue`.
185
+
186
+ Do not emit raw AG-UI interrupt events from middleware. Use the boundary hook
187
+ so the engine creates one terminal event and persistence records the batch.
188
+
136
189
  ### Pattern 1: Server-Only Tool
137
190
 
138
191
  Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tools })`.