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
One-Shot Extraction
chat() with outputSchema returns one typed object from text. It does not stream, store history, or run tools.
Quick setup#
1. Define the schema#
The descriptions go to the model as hints for each field:
// 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().meta({ description: "The email address" }),
});2. Await the object#
// extract.ts
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:
"Extract the person info: John Doe is 30 years old, email john@example.com",
},
],
threadId: "thread-1",
outputSchema: PersonSchema,
});
console.log(person.name, person.age, person.email);Run the file. It prints John Doe 30 john@example.com. The type of person is { name: string; age: number; email: string }, and it comes from PersonSchema.
How it works#
- outputSchema makes chat() return Promise<T>, where T is the type your schema infers.
- The engine validates the reply against your schema before the promise resolves.
- If the reply does not match, the promise rejects. See Handle errors.
Need the object to fill in while it streams? Read Streaming UIs. Need the user to change it over many turns? Read Multi-Turn Chat.
Send the object to the browser#
As plain JSON#
If the browser needs only the finished object, await it on the server and return it as JSON:
// api/extract-person.ts
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
import { PersonSchema } from "../schemas/person";
const RequestBody = z.object({ text: z.string() });
export async function POST(request: Request) {
const { text } = RequestBody.parse(await request.json());
const person = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: `Extract the person info: ${text}` }],
threadId: "thread-1",
outputSchema: PersonSchema,
});
return Response.json(person);
}On the client, fetch it and parse it with the same schema:
import { PersonSchema } from "./schemas/person";
const res = await fetch("/api/extract-person", {
method: "POST",
body: JSON.stringify({ text: "John Doe, 30, john@example.com" }),
});
const person = PersonSchema.parse(await res.json());
console.log(person.name);You own the request and the types. No TanStack client API is involved.
With useChat#
Use useChat when you want its loading state and a typed final. useChat reads a stream, so the server sends one with 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);
}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("Extract: John Doe, 30, john@example.com")}
>
Extract
</button>
{final && (
<p>
{final.name} is {final.age}
</p>
)}
</div>
);
}What you get from the hook:
- final: T | null. The full object, set when the run completes.
- partial: DeepPartial<T>. The same object as it fills in. You can ignore it for a one-shot UI. Streaming UIs shows how to use it.
- The schema on useChat gives the client its types and parses partial. The server validates the full object with the schema of chat({ outputSchema }). See Where validation runs.
Anthropic and Ollama send the object as one event. partial stays {}, and final arrives in one step. Your client code does not change.
To keep the awaited object on the server and still use useChat, send it with toJsonResponse(person). See Send a structured output.
Write a good schema#
- Describe ambiguous fields. price in which currency? duration in seconds or minutes? Put the answer in the description.
- Keep the schema small. Ask only for the fields you need. Small schemas give more reliable results.
- Make a field optional only when it can be missing. A required field makes the model invent a value.
- Use enums for fixed values.
- Try edge cases. Send empty, ambiguous, and noisy input, and look at what comes back.
Enums and descriptions together:
import { z } from "zod";
export const TicketSchema = z.object({
status: z.enum(["pending", "approved", "rejected"]),
priority: z.enum(["low", "medium", "high"]),
price: z.number().meta({ description: "Price in USD" }),
categories: z
.array(z.string())
.meta({ description: "Categories such as 'billing' or 'login'" }),
});Schemas can nest. The inferred type follows the structure:
import { chat } from "@tanstack/ai";
import { anthropicText } from "@tanstack/ai-anthropic";
import { z } from "zod";
const CompanySchema = z.object({
name: z.string(),
founded: z.number().meta({ description: "Year the company was founded" }),
headquarters: z.object({
city: z.string(),
country: z.string(),
address: z.string().optional(),
}),
employees: z.array(
z.object({
name: z.string(),
role: z.string(),
department: z.string(),
}),
),
financials: z
.object({
revenue: z.number().meta({ description: "Annual revenue in millions USD" }),
profitable: z.boolean(),
})
.optional(),
});
const article =
"Acme was founded in 1998 in Berlin, Germany. Jane Smith leads Sales as VP.";
const company = await chat({
adapter: anthropicText("claude-sonnet-5-5"),
messages: [{ role: "user", content: `Extract company info: ${article}` }],
threadId: "thread-1",
outputSchema: CompanySchema,
});
console.log(company.headquarters.city); // string
console.log(company.employees.map((employee) => employee.role)); // string[]
console.log(company.financials?.profitable); // boolean | undefinedHandle errors#
If the reply does not match your schema, chat() throws. The message names the fields that failed. Provider errors (auth, rate limit, network) throw the same way, so one try / catch handles both:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
try {
const person = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "John Doe is 30" }],
threadId: "thread-1",
outputSchema: PersonSchema,
});
console.log(person.name);
} catch (error) {
if (error instanceof Error) {
console.error("Structured output failed:", error.message);
}
}Advanced#
Using plain JSON Schema#
You can pass a JSON Schema object with no schema library. TypeScript cannot infer a type from it, so the result is unknown. Validate it before you use it.
import { chat } from "@tanstack/ai";
import type { JSONSchema } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const schema: JSONSchema = {
type: "object",
properties: {
name: { type: "string", description: "The name of the person" },
age: { type: "number", description: "The age of the person" },
},
required: ["name", "age"],
};
const result = await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Extract: John is 25 years old" }],
threadId: "thread-1",
outputSchema: schema,
});
// `result` is `unknown`. Narrow it before use.
if (typeof result === "object" && result !== null && "name" in result) {
console.log(result.name);
}A schema library is the better choice when you can use one, because you get the types.
Return types of chat()#
| Options | Return type |
|---|---|
| No outputSchema, stream: false | Promise<ChatResult> ({ text, chunks }) |
| No outputSchema, stream: true (the default for plain chat) | AsyncIterable<StreamChunk> |
| outputSchema (this page) | Promise<InferSchemaType<TSchema>> |
| outputSchema and stream: true | StructuredOutputStream<InferSchemaType<TSchema>>. See Streaming UIs. |
Read the provider error#
When the provider rejects the request, the message can be short, for example Provider returned error. The full error body of the provider is on error.cause:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
try {
await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "John Doe is 30" }],
threadId: "thread-1",
outputSchema: PersonSchema,
});
} catch (error) {
if (error instanceof Error) {
console.error(error.message, error.cause);
}
}Recover the model text#
Sometimes a model returns a valid JSON object and then more text, for example a second copy of the object. The JSON parse then fails, and chat() throws. The error message shows only the first 200 characters. The full text is on error.rawText:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";
try {
await chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "John Doe is 30" }],
threadId: "thread-1",
outputSchema: PersonSchema,
});
} catch (error) {
if (error instanceof Error && "rawText" in error) {
console.error("Model text:", error.rawText);
}
}When the JSON parses but does not match your schema, chat() throws an error with code: 'structured-output-validation-failed'. That error also has the model text on error.rawText.
To keep the answer, parse and validate that text yourself.