Pass outputSchema to a coding-agent chat() call. The agent still streams tool calls, and the run ends with a typed object.
The agent runs its native tools in the sandbox. Read the validated object from await chat() or from useChat().final.
This page is for the sandbox harness adapters:
To extract JSON from a prompt with no sandbox, use One-Shot Extraction with an HTTP adapter.
This setup assumes you have a sandbox provider ready. See Sandbox Quick Start.
// schemas/report.ts
import { z } from "zod";
export const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
audience: z.string(),
mainPackages: z.array(z.object({ name: z.string(), role: z.string() })),
howToRun: z.string(),
});Add withSandbox(...) to the middleware. Pass stream: true so the client gets a stream:
// api/repo-report.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import {
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
import { ReportSchema } from "../schemas/report";
const sandbox = defineSandbox({
id: "repo-report",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "TanStack/ai" }),
}),
});
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages,
threadId,
runId,
outputSchema: ReportSchema,
stream: true,
middleware: [withSandbox(sandbox)],
});
return toServerSentEventsResponse(stream);
}import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";
// The same schema as schemas/report.ts. In your app, import it from that file.
const ReportSchema = z.object({
name: z.string(),
oneLiner: z.string(),
audience: z.string(),
mainPackages: z.array(z.object({ name: z.string(), role: z.string() })),
howToRun: z.string(),
});
export function RepoReport() {
const { messages, sendMessage, isLoading, final } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
threadId: "support-chat",
outputSchema: ReportSchema,
});
return (
<>
<button
disabled={isLoading}
onClick={() => void sendMessage("What is this repository about?")}
>
Run report
</button>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "thinking") {
return <p key={index}>{part.content}</p>;
}
if (part.type === "tool-call") {
return (
<p key={part.id}>
{part.name} ({part.state})
</p>
);
}
if (part.type === "text") {
return <p key={index}>{part.content}</p>;
}
return null;
})}
</div>
))}
{final && (
<section>
<h2>{final.name}</h2>
<p>{final.oneLiner}</p>
</section>
)}
</>
);
}Click Run report. Tool calls such as Read and Bash appear first. When the agent finishes, the report shows the name and the one-line summary of the repo.
partial stays empty on harness adapters, because the object does not stream field by field. Show the tool calls while you wait.
To change the agent, swap the adapter:
If you do not stream to a browser, leave out stream: true. Then the promise resolves with the typed object:
import { chat } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import {
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
import { ReportSchema } from "./schemas/report";
const sandbox = defineSandbox({
id: "repo-report",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "TanStack/ai" }),
}),
});
const report = await chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "What is this repository about?" }],
threadId: "thread-1",
outputSchema: ReportSchema,
middleware: [withSandbox(sandbox)],
});
console.log(report.name, report.oneLiner);The React chat example has a repo-report page.
The page clones TanStack/ai into a sandbox, asks the agent to inspect it, and shows the validated report. It renders tool calls and reasoning from messages[].parts, and reads the object from the structured-output part and from final.
The assistant message holds the live run:
final has the type of your schema. It stays null until structured-output.complete arrives, and it always matches the latest assistant turn. Older turns keep their own structured-output parts. For the partial and final shape, see Streaming UIs.
| Adapter | How the schema is applied |
|---|---|
| Claude Code | Native --json-schema flag on the same turn. The value is inline JSON, not a file path. |
| Codex | Native --output-schema flag on the same turn. Assistant text streams while Codex writes it. The last message is also the schema object. |
| OpenCode | The schema goes into the prompt. The adapter parses the last assistant text. |
| Grok Build | The schema goes into the prompt. The adapter parses the last assistant text. |
| ACP compatible | The schema goes into the prompt. The adapter parses the last assistant text. |
OpenCode, Grok Build, and acpCompatible parse JSON from the last assistant message. If that message is not JSON, the parse fails. If the job is only extraction and you do not need a sandbox, use @tanstack/ai-openai or @tanstack/ai-grok.
Harness adapters run tools inside the sandbox. They do not pause for a round trip to the browser:
For approval gates or client tools, use With Tools with an HTTP adapter.
Add withPersistence next to withSandbox. The engine stores the complete structured-output part on the transcript. A reload rebuilds that transcript through reconstructChat. See Chat Persistence.
The message id decides how the parts are stored:
// api/repo-report.ts
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import { withPersistence } from "@tanstack/ai-persistence";
import {
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from "@tanstack/ai-sandbox";
import { dockerSandbox } from "@tanstack/ai-sandbox-docker";
import { persistence } from "../persistence";
import { ReportSchema } from "../schemas/report";
const sandbox = defineSandbox({
id: "repo-report",
provider: dockerSandbox({ image: "node:22" }),
workspace: defineWorkspace({
source: githubRepo({ repo: "TanStack/ai" }),
}),
});
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages,
threadId,
runId,
outputSchema: ReportSchema,
stream: true,
middleware: [withSandbox(sandbox), withPersistence(persistence)],
});
return toServerSentEventsResponse(stream);
}Claude Code does not ask you to accept a trust dialog for the cloned repo. The adapter loads only user settings, so the .claude/settings.json of the clone does not block headless -p. Host login uses your host claude login. The sandbox type does not decide this. See Harness Auth.