Persistence (Advanced)
- Build your first harness
- Host options
- Send and show media
- Connect clients
- Write a client in any language
- Access and tenants
- Run in the terminal
- Deploy a harness
- Send inputs safely
- Run a tool without a model
- Durable sessions
- Run background work on workers
- List, rename, and fork sessions
- Lifecycle and retention
- Run side effects once
- Wait for a person
- Build on the session log
- Control how a turn ends
- Store settings per thread
- Compact a long session
- Fork and reset a thread
- One log for a session tree
- Write a plugin
- Track usage and cost
- Ask before risky tool calls
- Give your agent coding tools
- Sandbox and extend the coding tools
- Run the coding tools for many threads
- Undo a turn
- Build a coding agent
- Delegate to coding agents
- Switch between agent profiles
- Auth and connectors
- Share a thread between people
- Connect model providers
- Build your own UI
- Code mode in a harness
- Work until a goal is met
- Run agents from a harness
- Hand off a conversation
- Add skills
- Use MCP servers
- Give your agent a computer
- Use from any MCP client
- Use a harness from an editor (ACP)
- Overview
- Quick Start
- ProvidersUpdated
- Harnesses
- Harness Auth
- Workspace
- Tools
- Policy
- Lifecycle & Snapshots
- Portable Snapshots
- Keep Files After Reload
- Save a Named Version
- Branch From a Version
- Send a Frozen File
- Let the Agent Save and Fork
- Pick Which Files to Keep
- What a Snapshot Stores
- Instance Durability
- Durable Runs
- Events
Sandboxes (Advanced)
- Runtime Context
- Bring Your Own Key
- Keep Keys on the Server
- Debug Logging
- Multimodal Content
- Files API
- Per-Model Type Safety
- Runtime Adapter Switching
- Tree-Shaking
- Extend Adapter
- Extend Adapter: Advanced
- Typed Pre-Configured Options
- Test with a Fake Model
- Prompt Caching
- Mid-Conversation Changes
- Approval Flow Processing
Streaming Structured Output UIs
A streaming structured output emits the object as the model writes it. The UI can render each finished field before the object is complete.
Stream the JSON instead. useChat parses it as it arrives and gives you a typed partial object that fills in field by field.
Quick setup#
This setup assumes chat already works in your app. For the schema file and the zod install, see the overview.
1. Stream from the server#
Pass outputSchema and stream: true:
// api/extract-person.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "../schemas/person";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
outputSchema: PersonSchema,
stream: true,
});
return toServerSentEventsResponse(stream);
}2. Render partial on the client#
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { PersonSchema } from "./schemas/person";
export function PersonExtractor() {
const { sendMessage, isLoading, partial, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
void sendMessage("Extract: John Doe, 30, john@example.com");
}}
>
<button disabled={isLoading}>Extract</button>
<p>Name: {partial.name ?? "..."}</p>
<p>Age: {partial.age ?? "..."}</p>
<p>Email: {partial.email ?? "..."}</p>
{final && <pre>{JSON.stringify(final, null, 2)}</pre>}
</form>
);
}Click Extract. The name, age, and email appear one after the other. When the run ends, the full object shows in the <pre> block.
For a full TanStack Start app that fills a table from partial.rows, open Streaming Structured Table.
How it works#
- partial is DeepPartial<T>. Every property and every array element is optional. The hook parses the JSON deltas as they arrive. It reads {} from sendMessage() until the first chunk.
- final is T | null. It is null until the run completes. Then it holds the full object from the structured-output.complete event.
- The schema on useChat gives you types. The server validates the full object with the schema of chat({ outputSchema }) before it sends final. If the object fails, the run ends with an error and final stays null. See Where validation runs.
Some adapters cannot stream the JSON. Anthropic and Ollama send the object as one event. Harness agents also send it at the end (see Harness Agents). In these cases partial stays {} until final is set. Your client code does not change.
If you leave out outputSchema, useChat returns its normal shape with no partial or final.
Vue, Solid, Svelte, Angular, Preact, and Octane take the same outputSchema option and return partial and final. Only the reactive type changes. See Quick Start for the code in each framework.
Show reasoning and tool calls too#
partial and final show only the object of the latest turn. Reasoning, tool calls, and the object of every turn are on messages[].parts:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
// The same schema as schemas/person.ts. In your app, import it from that file.
const PersonSchema = z.object({
name: z.string().meta({ description: "The full name of the person" }),
age: z.number().meta({ description: "The age in years" }),
email: z.string().email(),
});
export function PersonRun() {
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<div>
<button onClick={() => void sendMessage("Extract: John Doe, 30")}>
Extract
</button>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === "thinking") {
return <em key={i}>{part.content}</em>;
}
if (part.type === "tool-call") {
return (
<p key={part.id}>
{part.name} ({part.state})
</p>
);
}
if (part.type === "text") {
return <p key={i}>{part.content}</p>;
}
if (part.type === "structured-output") {
const person = part.data ?? part.partial;
return <p key={i}>Name: {person?.name ?? "..."}</p>;
}
return null;
})}
</div>
))}
</div>
);
}The structured-output part has these fields:
- data: the full object, after structured-output.complete.
- partial: the progressive parse of raw while the JSON streams.
- raw: the source JSON string.
- status: streaming, complete, or error.
- errorMessage: set when status is error.
final equals data on the latest assistant turn.
Advanced#
Where each chunk lands#
| Chunk type | Part on messages[i].parts |
|---|---|
| REASONING_MESSAGE_CONTENT | ThinkingPart on the assistant message |
| TOOL_CALL_START / _ARGS / _END | ToolCallPart on the assistant message |
| TOOL_CALL_RESULT | ToolResultPart on the tool message |
| TEXT_MESSAGE_CONTENT, with outputSchema | StructuredOutputPart on the assistant message. The JSON deltas build part.raw, and the parse fills part.partial. |
| TEXT_MESSAGE_CONTENT, no outputSchema | TextPart on the assistant message |
What the stream contains#
chat({ outputSchema, stream: true }) returns a StructuredOutputStream<T>. It is an AsyncIterable of the normal stream events, plus two CUSTOM events:
- structured-output.start fires once, when the JSON begins. It carries { messageId }. The client uses this id to send the next TEXT_MESSAGE_CONTENT deltas into a StructuredOutputPart on that message.
- structured-output.complete fires before RUN_FINISHED and carries the parsed object.
{
type: "CUSTOM",
name: "structured-output.complete",
value: {
object: T; // full parsed object
raw: string; // full JSON text
reasoning?: string; // only for thinking or reasoning models
},
// standard event fields, such as timestamp
// the model id is in metadata.tanstack when present
}If the parse fails, the stream emits RUN_ERROR in place of RUN_FINISHED.
At runtime, value on the complete event also holds the same messageId. The client uses it to find the right message. This field is not on the public StructuredOutputCompleteEvent<T> type, but you can read it from value if you need it.
Adapter coverage#
Streaming structured output works with every adapter. Only some send the JSON as a true stream in one request:
| Adapter | Behavior with outputSchema and stream: true |
|---|---|
| @tanstack/ai-openai | Native stream in one request (Responses API, text.format: json_schema) |
| @tanstack/ai-openrouter | Native stream in one request (response_format: json_schema) |
| @tanstack/ai-grok | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-groq | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-bedrock | Native stream through Converse or an OpenAI-compatible API |
| @tanstack/ai-byteplus | Native stream in one request on supported models. Other models emit RUN_ERROR. |
| @tanstack/ai-llmgateway | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-cloudflare | Native stream in one request (Chat Completions, response_format: json_schema) |
| @tanstack/ai-neon | Native stream in one request (Chat Completions, response_format: json_schema). Claude uses the Anthropic adapter. |
| @tanstack/ai-lovable | Native stream in one request (Responses or Chat Completions) |
| @tanstack/ai-gemini | Native stream in one request (generateContentStream, responseSchema) |
| Other adapters (Anthropic, Ollama, and more) | Runs structuredOutput without streaming, then emits the object as one structured-output.complete event |
Your client code is the same on all adapters. You see deltas only when the adapter implements structuredOutputStream.
Read the stream in a script#
With no HTTP boundary (a Node script, a CLI, a test), iterate the stream directly:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
threadId: "thread-1",
outputSchema: PersonSchema,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
// Typed from PersonSchema. Validate here if you need to.
console.log(chunk.value.object.name, chunk.value.object.age);
}
}This is the same StructuredOutputStream<T> that the server endpoint gives to toServerSentEventsResponse.
Watch each chunk with onChunk#
useChat also takes onChunk. The hook updates partial and final first. Then it calls your onChunk with the same chunk. Use it for a custom progress UI.
With tools#
When you set outputSchema, stream: true, and tools, the agent loop runs first. The structured stream emits its complete event only after every tool finishes. Tool approvals and client tools work the same as in a normal chat. See With Tools.