TanStack
Structured Outputs

Harness Structured Output

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.

Define the schema

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(),
});

The return type follows from the schema. You do not need a cast.

Server: sandbox plus outputSchema

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.

ts
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:

  • codexText("gpt-5.3-codex")
  • opencodeText("anthropic/claude-opus-4-5")
  • grokBuildText("composer-2.5")
  • acpCompatibleText(...) for any ACP CLI. See ACP-Compatible.

The typed object arrives as a structured-output.complete event. Tool activity streams first.

Client: read parts and final

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.

tsx
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:

  • thinking: harness reasoning, when the agent emits it
  • tool-call: native harness tools such as Read or Bash
  • text: prose the agent writes before the JSON
  • structured-output: the schema object. part.data is the validated value. part.partial is a progressive parse when the adapter streams JSON text. part.raw is the source string.

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.

How each harness applies the schema

AdapterHow the schema is applied
Claude CodeNative --json-schema flag on the same turn. The value is inline JSON, not a file path.
CodexNative --output-schema flag on the same turn. Assistant text streams as Codex writes it. The last message is also the schema object.
OpenCodeSchema is added to the prompt. The adapter parses the last assistant text.
Grok BuildSchema is added to the prompt. The adapter parses the last assistant text.
ACP compatibleSchema 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.

Approval gates and client tools

Harness adapters run tools inside the sandbox. They do not pause for a browser round-trip.

  • A tool without a server execute() fails fast.
  • A tool with needsApproval fails fast.

If you need approval gates or client tools, use With Tools with an HTTP adapter.

Script without a UI

If you do not stream to a browser, omit stream: true. The promise resolves with the typed object.

ts
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;

Try it

The React chat example includes a repo-report page.

  1. Open examples/ts-react-chat.
  2. Open /repo-report.
  3. Pick Claude Code, Grok Build, ACP compatible, or Codex.
  4. Pick Auth. The default is API key. Use Host login if the machine already ran claude login, grok login, or codex login.
  5. Run the report. The page renders tool calls and reasoning from messages[].parts. It reads the typed object from the structured-output part and from useChat().final.

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.