You asked a coding agent to inspect a repository. The agent streams tool calls and prose. You need a typed object you can store or render, not a wall of text to parse.
Pass outputSchema on the same chat() call. The harness runs its native tools. Then you get a validated object from await chat() or from useChat().final.
This page is for sandbox harness adapters:
If you only extract JSON from a prompt and you do not need a sandbox, use One-Shot Extraction with an HTTP adapter.
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(),
});The return type follows from the schema. You do not need a cast.
The harness needs a sandbox. Pass withSandbox(...). If the client reads the stream, pass stream: true. Without stream: true, chat() returns a Promise, not SSE.
import { chat, 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";
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 body: unknown = await request.json();
const messages =
typeof body === "object" &&
body !== null &&
"messages" in body &&
Array.isArray(body.messages)
? body.messages
: [];
const stream = chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages,
outputSchema: ReportSchema,
stream: true,
middleware: [withSandbox(sandbox)],
});
return toServerSentEventsResponse(stream);
}Swap the adapter to change the agent:
The typed object arrives as a structured-output.complete event. Tool activity streams first.
The assistant message holds the live run. Walk messages[].parts for tool calls, reasoning, and the typed object. useChat().final is a shortcut for the latest structured-output part.
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
function RepoReport() {
const { messages, sendMessage, isLoading, final } = useChat({
connection: fetchServerSentEvents("/api/repo-report"),
outputSchema: ReportSchema,
});
return (
<>
<button
disabled={isLoading}
onClick={() => 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>;
}
if (part.type === "structured-output") {
const report = part.data ?? part.partial;
return report?.name ? <h2 key={index}>{report.name}</h2> : null;
}
return null;
})}
</div>
))}
{final ? <p>{final.oneLiner}</p> : null}
</>
);
}Each part type:
final is typed as the schema. It stays null until structured-output.complete arrives. It always matches the latest assistant turn. Older turns stay on their own structured-output parts.
partial stays empty on harness adapters. The object is not streamed field by field. Render tool calls from messages while you wait. See Streaming UIs for the partial / final shape.
| 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 as Codex writes it. The last message is also the schema object. |
| OpenCode | Schema is added to the prompt. The adapter parses the last assistant text. |
| Grok Build | Schema is added to the prompt. The adapter parses the last assistant text. |
| ACP compatible | Schema is added to the prompt. The adapter parses the last assistant text. |
OpenCode, Grok Build, and acpCompatible parse JSON from the last assistant message. That parse fails if the message is not JSON. If the job is extract-only 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 browser round-trip.
If you need approval gates or client tools, use With Tools with an HTTP adapter.
If you do not stream to a browser, omit stream: true. The promise resolves with the typed object.
const report = await chat({
adapter: claudeCodeText("claude-opus-4-8"),
messages: [{ role: "user", content: "What is this repository about?" }],
outputSchema: ReportSchema,
middleware: [withSandbox(sandbox)],
});
report.name;
report.oneLiner;The React chat example includes a repo-report page.
The page clones TanStack/ai into a sandbox, asks the agent to inspect it, and shows the validated report.
Claude Code does not need you to accept a trust dialog for that clone. The adapter loads only user settings, so the clone's .claude/settings.json does not block headless -p. Host login uses your host claude login. The sandbox type does not pick this. See Harness Auth.