- 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
Harness Structured Output
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:
- Claude Code
- Codex
- OpenCode
- Grok Build
- ACP-Compatible (acpCompatible)
To extract JSON from a prompt with no sandbox, use One-Shot Extraction with an HTTP adapter.
Quick setup#
This setup assumes you have a sandbox provider ready. See Sandbox Quick Start.
1. Define the schema#
// 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(),
});2. Add the server endpoint#
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);
}3. Read the report on the client#
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.
How it works#
- withSandbox starts the sandbox and clones the repo.
- The agent runs its native tools inside the sandbox. Each call streams to the client as a tool-call part.
- The adapter applies your schema on the same turn. See How each harness applies the schema.
- The object arrives as a structured-output.complete event. useChat sets final.
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:
- codexText("gpt-5.3-codex")
- opencodeText("anthropic/claude-opus-4-5")
- grokBuildText("composer-2.5")
- acpCompatibleText(...) for any ACP CLI. See ACP-Compatible.
Script without a UI#
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);Try it in the example app#
The React chat example has a repo-report page.
- Open examples/ts-react-chat.
- Open /repo-report.
- Pick Claude Code, Grok Build, ACP compatible, or Codex.
- Pick Auth. The default is API key. If the machine already ran claude login, grok login, or codex login, use Host login.
- Run the report.
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.
Advanced#
Parts on the message#
The assistant message holds the live run:
- thinking: reasoning from the harness, when the agent emits it.
- tool-call: native harness tools, such as Read or Bash.
- text: prose that the agent writes before the JSON.
- structured-output: the schema object. part.data is the validated value, and part.raw is the source string. part.partial is a progressive parse, when the adapter streams JSON text.
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.
How each harness applies the schema#
| 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.
Approval gates and client tools#
Harness adapters run tools inside the sandbox. They do not pause for a round trip to the browser:
- A tool with no server execute() is still listed to the agent. When the agent calls it, the bridge returns an Unknown tool error to the agent, and the run goes on.
- A tool with needsApproval stops the run before the agent starts. The bridge cannot pause for approval, so it throws an error and does not run the tool.
For approval gates or client tools, use With Tools with an HTTP adapter.
Persist the typed object#
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:
- A new message id keeps the harness prose and the structured object as two assistant messages.
- The id of the last text message keeps both on one message.
// 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 and the trust dialog#
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.