Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/persist-structured-output-parts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/ai': patch
'@tanstack/ai-persistence': patch
---

Persist completed structured outputs as structured-output message parts and restore them during chat hydration.
36 changes: 22 additions & 14 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ graph TD
K --> L{Continue loop?}
L -->|Yes| D
L -->|No| H
H --> SO{outputSchema?}
SO -->|No| M{Outcome}
SO -->|Yes| SOC[onStructuredOutputConfig]
H --> SO{"Structured output path?"}
SO -->|None| M{Outcome}
SO -->|"Native combined"| SOH["Post-loop structured-output harvest (onChunk)"]
SOH --> M
SO -->|"Separate finalization"| SOC[onStructuredOutputConfig]
SOC --> SOM["onConfig (phase: structuredOutput)"]
SOM --> SOS["Structured-output finalization (onChunk, onUsage)"]
SOS --> M
Expand All @@ -86,6 +88,7 @@ graph TD
style SOC fill:#e1f5ff
style SOM fill:#e1f5ff
style SOS fill:#e1f5ff
style SOH fill:#e1f5ff
style N fill:#e1ffe1
style O fill:#fff4e1
style P fill:#ffe1e1
Expand All @@ -102,13 +105,13 @@ The context's `phase` field tracks where you are in the lifecycle:
| `modelStream` | While adapter streams chunks | `onChunk`, `onUsage` |
| `beforeTools` | Before tool execution | `onBeforeToolCall` |
| `afterTools` | After tool execution | `onAfterToolCall` |
| `structuredOutput` | During the final structured-output adapter call (when `outputSchema` is set **and** the adapter does not declare `supportsCombinedToolsAndSchema()`). Chunks from `adapter.structuredOutputStream` (or the synthesized non-streaming fallback) flow through `onChunk` with this phase, and `onUsage` fires for the final call's tokens. **Does not fire** for adapters that natively combine tools + schema in one streaming call (modern OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x family, and OpenRouter when every routed model is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`; see issue #605). On that path middleware observes the run through `beforeModel` / `modelStream` as usual. | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` |
| `structuredOutput` | During the final structured-output adapter call (when `outputSchema` is set **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options). Chunks from `adapter.structuredOutputStream` (or the synthesized non-streaming fallback) flow through `onChunk` with this phase, and `onUsage` fires for the final call's tokens. **Does not fire** for adapters that natively combine tools + schema in one streaming call (modern OpenAI Chat Completions, OpenAI Responses, Claude 4.5+, Gemini 3.x, Grok 4.x family, and OpenRouter when every routed model is in `OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS`; see issue #605). On that path middleware observes the run through `beforeModel` / `modelStream` as usual. | `onStructuredOutputConfig`, `onConfig`, `onChunk`, `onUsage` |

## Hooks Reference

### onConfig

Called once during `init` (startup) and once per iteration during `beforeModel` (before each model call). When `chat()` was invoked with `outputSchema`, `onConfig` additionally re-fires at the structured-output boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config — so a single-iteration run with `outputSchema` fires `onConfig` three times (`init` + `beforeModel` + `structuredOutput`). Use it to transform the configuration that the model receives.
Called once during `init` (startup) and once per iteration during `beforeModel` (before each model call). On the separate-finalization path, `onConfig` additionally re-fires at the structured-output boundary with `ctx.phase === 'structuredOutput'`, receiving the post-`onStructuredOutputConfig` view of the config. A single-iteration separate-finalization run therefore fires `onConfig` three times (`init` + `beforeModel` + `structuredOutput`). Native-combined output does not add this third call. Use `onConfig` to transform the configuration that the model receives.

Return a **partial** config object with only the fields you want to change — they are shallow-merged with the current config automatically. No need to spread the existing config.

Expand Down Expand Up @@ -164,9 +167,9 @@ When multiple middleware define `onConfig`, the config is **piped** through them

### onStructuredOutputConfig

Called once at the start of the final structured-output adapter call — only when `chat()` was invoked with `outputSchema` **and** the adapter takes the legacy finalization path (i.e. does not declare `supportsCombinedToolsAndSchema()`). Pipes through middleware in order, like `onConfig`, but with access to the **JSON Schema** being sent to the provider. Use this hook when you need to transform the schema (e.g., inject `$defs`, strip vendor-incompatible keywords) or apply structured-output-specific behavior (e.g., suppress system prompts on the final call).
Called once at the start of the final structured-output adapter call — only when `chat()` was invoked with `outputSchema` **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options. Pipes through middleware in order, like `onConfig`, but with access to the **JSON Schema** being sent to the provider. Use this hook when you need to transform the schema (e.g., inject `$defs`, strip vendor-incompatible keywords) or apply structured-output-specific behavior (e.g., suppress system prompts on the final call).

> Native-combined adapters (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x — see issue #605) skip the separate finalization call and never invoke this hook. If you need to mutate the schema for a native-combined adapter, do it in `onConfig` (the schema is on `config.modelOptions` / the request — adapter-specific).
> Native-combined adapters (modern OpenAI, Claude 4.5+, Gemini 3.x, Grok 4.x — see issue #605) skip the separate finalization call and never invoke this hook. The engine passes the converted schema directly to `chatStream` after `onConfig` runs, so middleware cannot transform the native-combined schema.

Return a **partial** `StructuredOutputMiddlewareConfig` with only the fields you want to change — they are shallow-merged with the current config. Return `void` to pass through.

Expand Down Expand Up @@ -274,7 +277,7 @@ There is **no separate `onStructuredOutputChunk` hook** — and you don't need o

How you distinguish them depends on which finalization path the adapter takes:

- **Separate-finalization adapters** (the legacy path — adapters that don't declare `supportsCombinedToolsAndSchema()`): `ctx.phase === 'structuredOutput'` during the finalization call. Discriminate on the phase.
- **Separate-finalization adapters** (`supportsCombinedToolsAndSchema()` does not return `true` for the current model/options): `ctx.phase === 'structuredOutput'` during the finalization call. Discriminate on the phase.
- **Native-combined adapters** (modern OpenAI Chat Completions / Responses, Claude 4.5+, Gemini 3.x, Grok 4.x — see issue #605): the schema-constrained JSON is produced on the model's natural final turn, so **`ctx.phase` stays `'modelStream'`** — the `'structuredOutput'` phase never fires. Discriminate on the CUSTOM event name (`structured-output.start` / `structured-output.complete`) instead.

```typescript ignore
Expand All @@ -297,7 +300,7 @@ const redactStructuredOutput: ChatMiddleware = {
};
}

// Both paths: the validated object arrives as a CUSTOM
// Both paths: the completed typed payload arrives as a CUSTOM
// `structured-output.complete` event. On the native-combined path this is
// your only signal (ctx.phase never flips to 'structuredOutput'), so key
// off the event name, not the phase. `chunk.value` carries { object, raw }.
Expand Down Expand Up @@ -622,15 +625,20 @@ Exactly **one** terminal hook fires per `chat()` invocation. They are mutually e
| `onAbort` | Run was aborted (via `ctx.abort()`, an external `AbortSignal`, or a `{ type: 'abort' }` decision from `onBeforeToolCall`) |
| `onError` | An unhandled error occurred |

> **Structured-output lifecycle ordering:** When `chat()` is invoked with `outputSchema`, `onFinish` fires **after** the structured-output finalization call completes — not at the end of the agent loop. `onIteration` does **not** fire for the finalization step; it only fires for agent-loop iterations.
> **Separate-finalization path:** Adapters without native-combined support make a separate structured-output provider call after the agent loop.
>
> **`onFinish` info fields and structured-output runs:** the `info` object reflects the **agent loop's** terminal state — finalization state is intentionally segregated to keep agent-loop semantics clean.
>
> - `info.content` — the agent loop's accumulated text. Finalization JSON deltas are **not** included here. The structured-output result is delivered via the `structured-output.complete` CUSTOM event, which middleware observes via `onChunk` (with `ctx.phase === 'structuredOutput'`).
> - `onStructuredOutputConfig` fires before the separate provider call, and `ctx.phase` is `'structuredOutput'` for its chunks.
> - `onIteration` does **not** fire for finalization; it only fires for agent-loop iterations.
> - `onFinish` fires after finalization completes. Its `info` object reflects the **agent loop's** terminal state.
> - `info.content` — the agent loop's accumulated text. Separate-finalization JSON deltas are **not** included. Middleware can observe the completed result through the `structured-output.complete` CUSTOM event in `onChunk`.
> - `info.usage` — the agent loop's last `RUN_FINISHED.usage`. For a tools-less structured-output run (no agent-loop iteration produces `RUN_FINISHED`), this is `undefined`. To capture finalization tokens, use `onUsage` — that hook fires for **every** `RUN_FINISHED` carrying usage, including the finalization call.
> - `info.finishReason` — the agent loop's last `finishReason`. `null` when no agent-loop iteration produced `RUN_FINISHED` (e.g. a tools-less structured-output run).
> - `info.duration` — wall-clock duration of the entire `chat()` invocation, including finalization.
>
> **Native-combined output:** Adapters with native-combined support produce the schema-constrained JSON in the regular agent-loop stream. `onStructuredOutputConfig` does not fire, `ctx.phase` remains `'modelStream'`, and `onIteration` fires for the iteration that produces the JSON. The JSON is agent-loop text, so `info.content` includes it. Middleware observes the `structured-output.complete` event in `onChunk` during the same phase.
>
> On successful completion in either path, `onFinish` receives the complete canonical transcript in `ctx.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 assistant message followed by a distinct structured-output assistant message. This transcript is separate from the path-specific fields on `info`.
>
> To aggregate usage across the whole run, accumulate from `onUsage` callbacks rather than relying on `info.usage`.

```typescript
Expand Down Expand Up @@ -660,7 +668,7 @@ The `info` object for `onFinish` (`FinishInfo`):
|-------|------|-------------|
| `finishReason` | `string \| null` | The agent loop's last `finishReason`. `null` when no agent-loop iteration produced `RUN_FINISHED` (e.g. a tools-less `chat({ outputSchema })` run). |
| `duration` | `number` | Total run duration in milliseconds, including any structured-output finalization. |
| `content` | `string` | The agent loop's accumulated text content. Does **not** include finalization JSON deltas — for that, observe the `structured-output.complete` CUSTOM event via `onChunk`. |
| `content` | `string` | The agent loop's accumulated text content. Includes native-combined structured JSON; excludes separate-finalization JSON. Observe the completed result through the `structured-output.complete` CUSTOM event via `onChunk`. |
| `usage` | `{ promptTokens; completionTokens; totalTokens } \| undefined` | **Optional.** The agent loop's last `RUN_FINISHED.usage`. **Does not include finalization tokens** — use `onUsage` to observe those. Always guard with `if (info.usage)` or `info.usage?.`. |

## Context Object
Expand Down
23 changes: 20 additions & 3 deletions docs/api/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,27 @@ An `AgentLoopStrategy` function.
### `ModelMessage`

```typescript
interface ModelMessage {
role: "user" | "assistant" | "system" | "tool";
content: string;
import type {
ContentPart,
StructuredOutputPart,
ToolCall,
} from "@tanstack/ai";

interface ModelMessage<
TContent extends string | null | ContentPart[] =
| string
| null
| ContentPart[],
> {
role: "user" | "assistant" | "tool";
content: TContent;
name?: string;
toolCalls?: ToolCall[];
toolCallId?: string;
thinking?: Array<{ content: string; signature?: string }>;
structuredOutput?: StructuredOutputPart;
id?: string;
createdAt?: Date;
}
```

Expand Down
2 changes: 1 addition & 1 deletion docs/chat/structured-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The structured-outputs guide has moved to its own top-level section, split by wh
- **[Overview](../structured-outputs/overview)** — what structured output is, schema library options, provider support, and "which page do I read?"
- **[One-Shot Extraction](../structured-outputs/one-shot)** — single prompt in, single typed object out. Use this when you don't need streaming or chat history.
- **[Streaming UIs](../structured-outputs/streaming)** — `useChat({ outputSchema })` with `partial` and `final` populating a UI field by field.
- **[Multi-Turn Chat](../structured-outputs/multi-turn)** — every assistant turn carries its own typed `StructuredOutputPart`, history stays renderable, and `messages[i].parts.find(p => p.type === "structured-output").data` is typed by your schema.
- **[Multi-Turn Chat](../structured-outputs/multi-turn)** — each successfully completed structured-output run adds a typed response to message history, and `messages[i].parts.find(p => p.type === "structured-output").data` is typed by your schema.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **[With Tools](../structured-outputs/with-tools)** — combining `outputSchema` with the agent loop, including pause/resume for server-tool approvals and client-tool invocations.
- **[Harness Agents](../structured-outputs/harnesses)** — a coding agent in a sandbox inspects files, then returns a typed object.

Expand Down
2 changes: 1 addition & 1 deletion docs/comparison/vercel-ai-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,7 @@ Vercel AI SDK's UI layer has three hooks: `useChat`, `useCompletion`, and `useOb

### Multi-Turn Structured Output

Structured output in TanStack AI is part of the conversation, not a separate call. Pass `outputSchema` to `useChat` and every assistant turn carries its own typed `StructuredOutputPart` - streamed as a `partial`, validated as a `final`, preserved in message history, with the schema generic threading all the way down to `messages[i].parts[j].data`.
TanStack AI preserves structured output in conversation history instead of leaving it only on a call result. Providers may produce it in the agent loop or through separate finalization; both paths create a typed `StructuredOutputPart`, streamed as a `partial` and completed as a `final`, with the schema generic threading all the way down to `messages[i].parts[j].data`.

Vercel AI SDK's structured output (`generateObject` / `streamObject` / `Output`) is per-call: the typed object lives on the call result, the message-part union has no structured-output type, and combining `useChat` with typed structured output means manually parsing model text into custom data parts.

Expand Down
10 changes: 6 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@
"label": "Chat Persistence",
"to": "persistence/chat-persistence",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-13"
"updatedAt": "2026-08-19"
},
{
"label": "Client Persistence",
Expand Down Expand Up @@ -340,7 +340,8 @@
{
"label": "How Persistence Works",
"to": "persistence/internals",
"addedAt": "2026-08-04"
"addedAt": "2026-08-04",
"updatedAt": "2026-08-19"
}
]
},
Expand Down Expand Up @@ -380,7 +381,8 @@
{
"label": "Multi-Turn Chat",
"to": "structured-outputs/multi-turn",
"addedAt": "2026-05-19"
"addedAt": "2026-05-19",
"updatedAt": "2026-08-19"
},
{
"label": "With Tools",
Expand All @@ -392,7 +394,7 @@
"label": "Harness Agents",
"to": "structured-outputs/harnesses",
"addedAt": "2026-08-14",
"updatedAt": "2026-08-18"
"updatedAt": "2026-08-19"
}
]
},
Expand Down
48 changes: 47 additions & 1 deletion docs/persistence/chat-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ generation hooks. [How persistence works](./internals) has the rest.
| --- | --- | --- |
| **Start of a run** (`onStart`) | Pending turn (just-submitted user message + prior history) so a reload mid-generation still shows the question | Yes. Failure does not abort the run; finish is authoritative |
| **Interrupt boundary** | New interrupt records, run status `interrupted`, known usage, and a thread snapshot of current messages | No. Store failures propagate |
| **Finish** (`onFinish`) | Complete transcript (including the terminal assistant reply with its stream `messageId` for in-place reload identity), run status `completed`, known usage, and commit of consumed resumes | No. The transcript is saved **before** the run is marked completed |
| **Finish** (`onFinish`) | Complete transcript (including completed assistant messages, the terminal reply's stream `messageId` for in-place reload identity, and any completed structured-output part), run status `completed`, known usage, and commit of consumed resumes | No. The transcript is saved **before** the run is marked completed |
| **Optionally while streaming** | Throttled partial assistant text when `snapshotStreaming: true` | Yes |

```ts group=chat-persistence
Expand All @@ -112,6 +112,52 @@ Streaming snapshots default off (finish is the authoritative save); enable
them to trade extra writes for partial-output durability. Tune the interval
with `snapshotIntervalMs` (default `1000`).

The chat engine completes the canonical transcript before `onFinish` runs, and
`withPersistence` saves that transcript directly.

- Native-combined output keeps the structured result on its terminal assistant
message.
- Separate finalization can preserve a plain-text assistant message followed by
a structured-output assistant message.
- Harness adapters emit `structured-output.complete` during the run. A new
message id stores prose and structured output as two assistant messages. The
last text message id keeps both on one assistant message.

A server-authoritative client hydrates that transcript on mount. Walk
`messages[].parts` for the reconstructed structured-output part:

```tsx group=chat-persistence
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
import { z } from 'zod'

const PersonSchema = z.object({ name: z.string() })

function PersistentStructuredChat({ threadId }: { threadId: string }) {
const { messages } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
outputSchema: PersonSchema,
})

return (
<div>
{messages.map((message) => {
const part = message.parts.find(
(candidate) => candidate.type === 'structured-output',
)
if (!part) return null
const person = part.data ?? part.partial
return <p key={message.id}>{person?.name}</p>
})}
</div>
)
}
```

The matching server `GET` uses `reconstructChat`. See
[Client persistence](./client-persistence).

Comment thread
AlemTuzlak marked this conversation as resolved.
On **error**, the run is marked `failed`. On **abort**, the run is marked
`aborted` with a `finishedAt`; `interrupted` is written only at an interrupt
boundary, and it is not terminal. Both terminal paths retain usage reported
Expand Down
Loading
Loading