- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
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 streaming path does not validate the object with your schema. If you need validation, do it in your own code.
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-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.