- 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
Structured Outputs Overview
Pass outputSchema so the model returns an object that matches the schema. The call fails when the output does not match.
Pass a schema as outputSchema. The model must answer in that shape, and you get a typed object back.
Quick setup#
This setup adds a "person extractor" to an app that already has chat. It takes about 5 minutes.
1. Install a schema library#
npm i zodpnpm add zodyarn add zodbun add zod2. Define the schema#
Put the schema in a file that the server and the client can both import:
// schemas/person.ts
import { z } from "zod";
export 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(),
});3. Add the server endpoint#
// 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);
}4. Read the object on the client#
Give useChat the same schema. Then read final:
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { PersonSchema } from "./schemas/person";
export function PersonExtractor() {
const { sendMessage, isLoading, final } = useChat({
connection: fetchServerSentEvents("/api/extract-person"),
threadId: "support-chat",
outputSchema: PersonSchema,
});
return (
<div>
<button
disabled={isLoading}
onClick={() => void sendMessage("John Doe is 30, john@example.com")}
>
Extract
</button>
{final && (
<p>
{final.name} ({final.age}): {final.email}
</p>
)}
</div>
);
}Click Extract. When the run ends, the card shows John Doe (30): john@example.com. final.age is a number, with no cast.
Vue, Solid, Svelte, Angular, Preact, and Octane take the same outputSchema option and return the same partial and final values.
How it works#
- TanStack AI converts your schema to JSON Schema.
- The adapter sends that JSON Schema to the native structured-output API of the provider.
- The server streams the JSON. The last event, structured-output.complete, holds the full object.
- useChat sets final from that event. TypeScript infers its type from the schema.
No UI? Await the object#
In a script, a CLI, or a server job, leave out stream: true. Then chat() returns a promise of the typed object:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
const person = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "John Doe is 30, john@example.com" }],
threadId: "thread-1",
outputSchema: PersonSchema,
});
console.log(person.name, person.age);Pick your next page#
| You want to | Read |
|---|---|
| Extract one object, with field hints and error handling | One-Shot Extraction |
| Fill a form or card field by field while the JSON streams | Streaming UIs |
| Fill a table from streamed rows in a TanStack Start app | Streaming Structured Table |
| Let the user change the object over many turns, and keep each version | Multi-Turn Chat |
| Call tools first, then return a typed object | With Tools |
| Let a coding agent read files in a sandbox, then return a typed object | Harness Agents |
Advanced#
Schema libraries#
outputSchema accepts any library that implements Standard JSON Schema:
- Zod (v4.2+)
- ArkType
- Valibot (with @valibot/to-json-schema)
- Plain JSON Schema objects. You lose type inference. See Using plain JSON Schema.
For field descriptions, refinements, and enums, read the docs of your schema library. TanStack AI converts the schema to JSON Schema for you.
Provider support#
Each adapter uses the native structured-output API of its provider. The same chat({ outputSchema }) call works with all of them.
| Provider | Implementation |
|---|---|
| OpenAI | response_format with json_schema |
| Anthropic | Tool-based extraction |
| Google Gemini | responseSchema |
| Ollama | JSON mode with schema |
| OpenRouter, Grok, Groq | response_format with json_schema |
| Claude Code, Codex | Native schema flag on the same harness turn (--json-schema, --output-schema) |
| OpenCode, Grok Build, ACP compatible | Schema in the prompt, then the adapter parses the reply on the same turn |
For a coding agent in a sandbox, see Harness Agents.
When strict JSON schema is rejected#
Some models reject strict JSON schema. They accept only JSON mode (response_format type json_object). A strict request to one of these models fails before the model runs.
The OpenRouter and Groq adapters find these models in their catalogs. For these models, chat({ outputSchema }) does two things:
- It sends JSON mode.
- It adds your schema to the system prompt, because JSON mode does not send the schema.
If your schema is a Standard Schema, such as Zod, await chat({ outputSchema }) still validates the reply. JSON of the wrong shape throws. TanStack AI does not validate a plain JSON Schema or a streamed reply. Validate those in your own code.
import { chat } from "@tanstack/ai";
import { openRouterText } from "@tanstack/ai-openrouter";
import { z } from "zod";
const Person = z.object({ name: z.string(), age: z.number() });
const person = await chat({
adapter: openRouterText("qwen/qwen3.7-flash"),
messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }],
threadId: "thread-1",
outputSchema: Person,
});
console.log(person.name, person.age);To send JSON mode to a model that is not in the catalog, set it in modelOptions:
- OpenRouter: modelOptions: { responseFormat: { type: "json_object" } }
- Groq: modelOptions: { response_format: { type: "json_object" } }
Anthropic schema complexity limits#
Anthropic compiles a structured-output schema into a grammar. It rejects a schema that is too large or too complex with a 400 error. Two common messages:
- Schema is too complex for compilation
- output_config.format.schema: Invalid schema: The compiled grammar is too large
This applies to Claude models directly and to anthropic/* models on OpenRouter. Other providers can accept the same schema.
If you get one of these errors, make the schema simpler. Constructs that add branches to the grammar cost more than size:
- .optional() fields that you do not need
- .catch() and .default() wrappers, because they widen the accepted input
- Union types and deeply nested objects
- Optional strings with no constraint. Use enums or formats where you can.
Anthropic changes its limits and does not publish all of them. For the current limits, read the Anthropic structured outputs documentation.
Where validation runs#
Validation on the server depends on the path:
- await chat({ outputSchema }): the engine validates the object with your Standard Schema when the run ends. A failure goes to the onError middleware hook, and the promise rejects.
- chat({ outputSchema, stream: true }): the engine does not validate. Read the object from value.object on the structured-output.complete event, or call parseWithStandardSchema on the raw text yourself.
On the client, the schema you give useChat is for TypeScript types and for the progressive parse of partial.
Optional fields come back absent#
Strict providers need every field to be required. So the engine sends each optional field as required and nullable, and the provider returns null for a missing optional field. Before you get the object, the engine undoes this change:
- An .optional() field that came back null is absent, which matches T | undefined.
- A .nullable() field keeps its null.
This applies to the awaited result and to value.object on the stream.
Middleware#
Middleware on chat() sees the agent loop and the final structured-output call. Chunks from that final call have ctx.phase === "structuredOutput". onFinish fires once, at the end of the full run.
Some adapters return tool calls and the schema-constrained answer in one streaming request. On these adapters there is no separate final call:
- The engine adds outputSchema to the normal request and reads the object from the text of the last turn.
- The "structuredOutput" phase does not fire. Middleware sees the run through "beforeModel" and "modelStream".
- onStructuredOutputConfig does not run.
Adapters with this combined mode:
- OpenAI (Chat Completions and Responses)
- Anthropic Claude 4.5 and newer
- Gemini 3.x
- Grok 4.x
- OpenRouter, when every routed model is in OPENROUTER_COMBINED_TOOLS_AND_SCHEMA_MODELS. See the OpenRouter adapter.
Other adapters make a separate final call, and the "structuredOutput" phase fires. These include Anthropic 4.4 and older, Gemini 2.x, Grok 2 and 3, Groq, Ollama, and OpenRouter models outside that set.
To see the structured-output chunks, read ctx.phase in onChunk:
import type { ChatMiddleware } from "@tanstack/ai";
import { span } from "./span";
const tracing: ChatMiddleware = {
name: "tracing",
onChunk(ctx, chunk) {
// Fires for chunks from the agent loop and from the final structured-output call.
if ("type" in chunk) {
span.addEvent("chunk", { phase: ctx.phase, type: chunk.type });
}
},
};To change the JSON Schema before the provider call, use onStructuredOutputConfig:
import type { ChatMiddleware } from "@tanstack/ai";
import { sharedDefs } from "./schema-defs";
const injectDefs: ChatMiddleware = {
name: "inject-defs",
onStructuredOutputConfig(_ctx, config) {
return {
outputSchema: { ...config.outputSchema, $defs: { ...sharedDefs } },
};
},
};For all hooks, see Middleware.