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.
This setup adds a "person extractor" to an app that already has chat. It takes about 5 minutes.
npm i zodpnpm add zodyarn add zodbun add zodPut 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(),
});// 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);
}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.
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);| 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 |
outputSchema accepts any library that implements Standard 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.
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.
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:
If your schema is a Standard Schema, such as Zod, chat({ outputSchema }) still validates the reply. JSON of the wrong shape fails the run. TanStack AI does not validate a plain JSON Schema. Validate that 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:
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:
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:
Anthropic changes its limits and does not publish all of them. For the current limits, read the Anthropic structured outputs documentation.
Validation on the server depends on the path:
A failed RUN_ERROR has a code and the text of the model:
import { EventType, chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
const Person = z.object({ name: z.string(), age: z.number() });
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Return Ada Lovelace, age 36." }],
threadId: "thread-1",
outputSchema: Person,
stream: true,
});
for await (const chunk of stream) {
if (chunk.type === EventType.RUN_ERROR) {
console.error(chunk.code, chunk.metadata?.tanstack?.rawText);
}
}The engine does not validate a plain JSON Schema on either path.
On the client, the schema you give useChat is for TypeScript types and for the progressive parse of partial.
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:
This applies to the awaited result and to value.object on the stream.
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:
Adapters with this combined mode:
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.