diff --git a/.changeset/harness-output-schema.md b/.changeset/harness-output-schema.md
new file mode 100644
index 0000000000..a4a5bbb703
--- /dev/null
+++ b/.changeset/harness-output-schema.md
@@ -0,0 +1,12 @@
+---
+'@tanstack/ai': minor
+'@tanstack/ai-claude-code': minor
+'@tanstack/ai-codex': minor
+'@tanstack/ai-opencode': minor
+'@tanstack/ai-grok-build': minor
+'@tanstack/ai-acp': minor
+---
+
+Harness adapters honor `chat({ outputSchema })` on the same turn.
+
+Claude Code and Codex pass a native schema flag. OpenCode, Grok Build, and `acpCompatible` parse JSON from the final assistant text. The engine reads a `structured-output.complete` event so harness prose is not parsed as JSON.
diff --git a/.gitignore b/.gitignore
index a1b0907740..a65d10233d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -72,6 +72,7 @@ docs/superpowers
# Only .claude.settings.json should be committed
.claude/settings.local.json
.claude/worktrees/*
+/worktrees/
.claude/scheduled_tasks.lock
solo.yml
diff --git a/docs/adapters/acp-compatible.md b/docs/adapters/acp-compatible.md
index 54c03fd4e5..d311b47071 100644
--- a/docs/adapters/acp-compatible.md
+++ b/docs/adapters/acp-compatible.md
@@ -16,6 +16,33 @@ Coding-agent CLIs that speak the [Agent Client Protocol](https://agentclientprot
It is the harness equivalent of the [OpenAI-Compatible adapter](./openai-compatible). Use it when your agent speaks ACP but has no `@tanstack/ai-*` package. If a dedicated harness adapter exists ([Grok Build](./grok-build), and others), prefer it — those carry curated per-model metadata and vendor-specific behavior.
+## Authentication
+
+The default `authMode` is `'api-key'`. Set `'host'` when the agent should use
+a CLI login on the machine. The sandbox type does not pick this. See
+[Harness Auth](../sandbox/auth).
+
+```ts
+import { acpCompatibleText } from "@tanstack/ai-acp"
+
+acpCompatibleText("composer-2.5", {
+ name: "acp",
+ command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
+ authMethodId: "xai.api_key",
+})
+
+acpCompatibleText("composer-2.5", {
+ name: "acp",
+ command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
+ authMode: "host",
+})
+```
+
+- `'api-key'` (default): call `authenticate` with `authMethodId`.
+- `'host'`: skip ACP `authenticate`. Use the CLI login on the machine.
+
+Pass `outputSchema` on the same `chat()` call. `acpCompatible` adds the schema to the prompt and parses the last assistant text. Native harness tools still run on that turn. Read the typed object from `await chat()`, from `useChat().final`, or from the assistant `structured-output` part. See [Harness Agents](../structured-outputs/harnesses).
+
## Installation
`acpCompatible` ships in `@tanstack/ai-acp`. You drive it inside a sandbox, so install the sandbox package and a provider too:
@@ -89,6 +116,77 @@ const stream = chat({
})
```
+## Typed output
+
+Pass `outputSchema` on the same `chat()` call. The agent runs its native tools. Then the adapter parses the last assistant text as JSON.
+
+```ts
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { acpCompatibleText } from '@tanstack/ai-acp'
+import { withSandbox } from '@tanstack/ai-sandbox'
+import { z } from 'zod'
+import { sandbox } from './sandbox'
+import { messages } from './chat-context'
+
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+})
+
+export async function POST() {
+ const stream = chat({
+ adapter: acpCompatibleText('pi-fast', {
+ name: 'pi',
+ command: ({ model }) => `pi --acp -m ${model}`,
+ }),
+ messages,
+ outputSchema: ReportSchema,
+ stream: true,
+ middleware: [withSandbox(sandbox)],
+ })
+ return toServerSentEventsResponse(stream)
+}
+```
+
+On the client, walk `messages[].parts` for tool calls and reasoning. Read the typed object from the `structured-output` part or from `useChat().final`.
+
+```tsx
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { z } from 'zod'
+
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+})
+
+function Report() {
+ const { messages, final } = useChat({
+ connection: fetchServerSentEvents('/api/report'),
+ outputSchema: ReportSchema,
+ })
+
+ return (
+ <>
+ {messages.map((message) =>
+ message.parts.map((part, index) => {
+ if (part.type === 'tool-call') {
+ return
: null}
+ >
+ )
+}
+```
+
+See [Harness Agents](../structured-outputs/harnesses) for the full part list and the repo-report example.
+
## Typed models & options
Like `openaiCompatible`, you can declare the harness's **models** and its
@@ -130,7 +228,8 @@ const stream = chat({
```
The base options are always available on `modelOptions` regardless of what you
-declare: `sessionId` (resume), `cwd`, `authMethodId`, and `permissionMode`.
+declare: `sessionId` (resume), `cwd`, `authMode`, `authMethodId`, and
+`permissionMode`.
## Configuration
@@ -144,7 +243,8 @@ declare: `sessionId` (resume), `cwd`, `authMethodId`, and `permissionMode`.
| `openTransport` | Open any `AcpSessionTransport` yourself (e.g. boot a `serve` process and connect over WebSocket). Overrides `command`. |
| `cwd` | Working directory inside the sandbox (default `/workspace`). |
| `env` | Extra environment variables for the harness process. |
-| `authMethodId` | ACP auth method to select before the session starts. |
+| `authMode` | `'api-key'` (default) uses `authMethodId`. `'host'` skips ACP `authenticate`. See [Harness Auth](../sandbox/auth). |
+| `authMethodId` | ACP auth method to select before the session starts. Ignored when `authMode` is `'host'`. |
| `permissionMode` | `'default'` \| `'acceptEdits'` \| `'bypassPermissions'` (default). |
| `permissions` | `'headless'` (auto-resolve, default) or `'interactive'` (emit approval-requested events for `ask` prompts). |
| `onPermissionRequest` | Custom permission handler; overrides `permissions`/`permissionMode`. |
diff --git a/docs/adapters/claude-code.md b/docs/adapters/claude-code.md
index 01bfaeb300..f360bcd3ba 100644
--- a/docs/adapters/claude-code.md
+++ b/docs/adapters/claude-code.md
@@ -28,10 +28,19 @@ A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanS
## Authentication
-The harness resolves credentials the same way Claude Code does:
+Your laptop can already have `claude login`. A CI runner only has
+`ANTHROPIC_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'`
+when you want `claude login`. See [Harness Auth](../sandbox/auth).
-- `ANTHROPIC_API_KEY` in the server's environment (or the `apiKey` config option), or
-- an existing Claude subscription login on the machine (`claude login`).
+```ts
+import { claudeCodeText } from "@tanstack/ai-claude-code"
+
+claudeCodeText("claude-opus-4-8")
+claudeCodeText("claude-opus-4-8", { authMode: "host" })
+```
+
+- `'api-key'` (default): inject `ANTHROPIC_API_KEY` (or pass `apiKey`).
+- `'host'`: use `claude login`. Do not inject `ANTHROPIC_API_KEY`.
## Basic Usage
@@ -59,6 +68,7 @@ const stream = chat({
| `maxTurns` | Maximum harness-internal turns per run. |
| `systemPromptMode` | `'append'` (default) keeps Claude Code's preset system prompt and appends your `systemPrompts`; `'replace'` sends yours as the entire prompt. |
| `mcpServers` | Extra MCP servers passed through to the harness untouched. |
+| `authMode` | `'api-key'` (default) injects `ANTHROPIC_API_KEY`. `'host'` uses `claude login`. Also valid on `modelOptions`. See [Harness Auth](../sandbox/auth). |
| `apiKey` | Anthropic API key for the harness subprocess. |
| `env` | Extra environment variables for the harness subprocess. |
| `pathToClaudeCodeExecutable` | Use a specific Claude Code executable instead of the SDK's bundled one. |
@@ -170,7 +180,65 @@ const stream = chat({
## Structured Output
-`structuredOutput()` uses the harness's native JSON-schema output format in a one-shot run (single turn, no tools). It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-anthropic`) is the better choice when structured extraction is the primary job — it's faster and doesn't spawn a subprocess.
+Pass `outputSchema` on `chat()`. Claude Code runs one harness turn, uses its native tools, and returns a typed object. The schema JSON is passed to `--json-schema` as inline JSON (the CLI rejects a file path). Tool activity and prose stream as usual. The object arrives as `structured-output.complete`, including when Claude delivers it through its built-in `StructuredOutput` tool.
+
+The adapter loads only user settings (`--setting-sources user`). A cloned repo's `.claude/settings.json` does not block headless `-p`. The adapter does not pass `--bare`, because that flag ignores a host `claude login`.
+
+On local-process, Claude uses your host `claude login`. On Docker, pass `ANTHROPIC_API_KEY` in the process env. A container has no host login.
+
+```ts
+import { chat } from "@tanstack/ai"
+import { claudeCodeText } from "@tanstack/ai-claude-code"
+import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+const sandbox = defineSandbox({
+ id: "repo-report",
+ provider: dockerSandbox({ image: "node:22" }),
+})
+
+const report = await chat({
+ adapter: claudeCodeText("claude-opus-4-8"),
+ messages: [{ role: "user", content: "Review this repo." }],
+ outputSchema: Report,
+ middleware: [withSandbox(sandbox)],
+})
+
+report.summary
+```
+
+On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
+
+```tsx
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+function ReportView() {
+ const { final, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/repo-report"),
+ outputSchema: Report,
+ })
+
+ if (isLoading) return
The agent is inspecting the repo.
+ if (!final) return null
+ return
{final.summary}
+}
+```
+
+If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-anthropic`. That path is faster.
+
+Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
## Limitations
diff --git a/docs/adapters/codex.md b/docs/adapters/codex.md
index eb000dd053..4448b9603e 100644
--- a/docs/adapters/codex.md
+++ b/docs/adapters/codex.md
@@ -28,10 +28,19 @@ A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanS
## Authentication
-The harness resolves credentials the same way the Codex CLI does:
+Your laptop can already have `codex login`. A CI runner only has
+`CODEX_API_KEY`. The default `authMode` is `'api-key'`. Set `'host'` when
+you want `codex login`. See [Harness Auth](../sandbox/auth).
-- the `apiKey` config option (exported to the subprocess as `CODEX_API_KEY`; usage-based billing), or
-- an existing ChatGPT login on the machine (`codex login`).
+```ts
+import { codexText } from "@tanstack/ai-codex"
+
+codexText("gpt-5.5")
+codexText("gpt-5.5", { authMode: "host" })
+```
+
+- `'api-key'` (default): expect `CODEX_API_KEY` (or pass `apiKey`).
+- `'host'`: use `codex login`. Do not inject `CODEX_API_KEY`.
## Basic Usage
@@ -60,13 +69,16 @@ const stream = chat({
| `networkAccessEnabled` | Allow network access inside the `workspace-write` sandbox. |
| `webSearchMode` | `'disabled'` \| `'cached'` \| `'live'`. |
| `additionalDirectories`| Extra writable directories beyond `cwd`. |
+| `authMode` | `'api-key'` (default) expects `CODEX_API_KEY`. `'host'` uses `codex login`. See [Harness Auth](../sandbox/auth). |
| `apiKey` | OpenAI API key for the harness subprocess. |
| `baseUrl` | Override the Codex backend base URL. |
| `codexPathOverride` | Use a specific codex executable instead of the SDK's bundled binary. |
| `env` | Environment variables for the subprocess. When set, `process.env` is **not** inherited (Codex SDK semantics). |
| `config` | Extra `--config key=value` overrides passed to the Codex CLI (e.g. additional `mcp_servers` entries). |
-Per-call overrides — `sessionId`, `sandboxMode`, `approvalPolicy`, `modelReasoningEffort`, `workingDirectory`, `skipGitRepoCheck` — go through `modelOptions`.
+Per-call overrides go through `modelOptions`: `sessionId`, `sandboxMode`,
+`approvalPolicy`, `modelReasoningEffort`, `workingDirectory`,
+`skipGitRepoCheck`, and `authMode`.
## Stateful Sessions
@@ -170,7 +182,61 @@ const stream = chat({
## Structured Output
-`structuredOutput()` uses Codex's native `outputSchema` support in a fresh, read-only, one-shot thread whose final message is a JSON string conforming to your schema. It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-openai`) is the better choice when structured extraction is the primary job — it's faster and doesn't spawn a subprocess.
+Pass `outputSchema` on `chat()`. Codex runs one harness turn and constrains the last message with `--output-schema`. Tool activity and assistant text stream as Codex writes them. The last message is also parsed as the schema object and arrives as `structured-output.complete`.
+
+```ts
+import { chat } from "@tanstack/ai"
+import { codexText } from "@tanstack/ai-codex"
+import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+const sandbox = defineSandbox({
+ id: "repo-report",
+ provider: dockerSandbox({ image: "node:22" }),
+})
+
+const report = await chat({
+ adapter: codexText("gpt-5.3-codex"),
+ messages: [{ role: "user", content: "Review this repo." }],
+ outputSchema: Report,
+ middleware: [withSandbox(sandbox)],
+})
+
+report.summary
+```
+
+On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
+
+```tsx
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+function ReportView() {
+ const { final, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/repo-report"),
+ outputSchema: Report,
+ })
+
+ if (isLoading) return
The agent is inspecting the repo.
+ if (!final) return null
+ return
{final.summary}
+}
+```
+
+If you only need to extract JSON from a prompt and do not need a sandbox, use `@tanstack/ai-openai`. That path is faster.
+
+Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
## Limitations
diff --git a/docs/adapters/grok-build.md b/docs/adapters/grok-build.md
index 7d1e6600cc..899dffeb09 100644
--- a/docs/adapters/grok-build.md
+++ b/docs/adapters/grok-build.md
@@ -38,14 +38,23 @@ You also need a sandbox provider (e.g. `@tanstack/ai-sandbox-docker`) and the
## Authentication
-Grok Build resolves credentials the same way the `grok` CLI does:
+Your laptop can already have `grok login`. A CI runner only has `XAI_API_KEY`.
+Both can use the same sandbox provider. The default `authMode` is `'api-key'`.
+Set `'host'` when you want `grok login`. See [Harness Auth](../sandbox/auth).
-- the `XAI_API_KEY` environment variable (headless / sandbox — inject it as a
- workspace secret), or
-- an existing grok.com browser login on the machine (local dev).
+```ts
+import { grokBuildText } from "@tanstack/ai-grok-build"
+
+grokBuildText("composer-2.5")
+grokBuildText("composer-2.5", { authMode: "host" })
+```
+
+- `'api-key'` (default): inject `XAI_API_KEY` and authenticate with it.
+- `'host'`: skip ACP `authenticate`. Use `grok login`. Do not inject
+ `XAI_API_KEY` into that process.
-The two auth modes expose the model under slightly different ids; the adapter
-maps the short alias for you (see [Models](#models)).
+The two modes list the model under slightly different ids. The adapter maps
+the short alias for you (see [Models](#models)).
## Basic Usage
@@ -74,7 +83,7 @@ const sandbox = defineSandbox({
const stream = chat({
threadId,
- adapter: grokBuildText('grok-build'),
+ adapter: grokBuildText('composer-2.5'),
messages,
middleware: [withSandbox(sandbox)],
})
@@ -106,6 +115,8 @@ Adapter config (second argument to `grokBuildText`):
| `env` | Extra environment variables for the `grok` process inside the sandbox. |
| `emitDiff` | Emit a `file.changed` CUSTOM event with the working-tree `git diff` after the run. Defaults to `true`. |
| `protocol` | Harness wire protocol: `'acp'` or `'streaming-json'`. Defaults to `'acp'`. A durable sandbox run with no protocol set uses `'streaming-json'` so the run can journal. |
+| `authMode` | `'api-key'` (default) uses `XAI_API_KEY`. `'host'` uses `grok login`. See [Harness Auth](../sandbox/auth). |
+| `authMethodId` | Explicit ACP auth method. Wins over `authMode`. |
| `extraArgs` | Extra raw CLI flags appended verbatim (advanced). |
Per-call overrides go through `modelOptions`:
@@ -116,6 +127,8 @@ Per-call overrides go through `modelOptions`:
| `cwd` | Per-call override of the harness working directory. |
| `maxTurns` | Per-call cap on the number of harness turns. |
| `protocol` | Per-call override of the harness wire protocol. |
+| `authMode` | Per-call `'host'` or `'api-key'`. Default `'api-key'`. |
+| `authMethodId` | Per-call ACP auth method. Wins over `authMode`. |
## Stateful Sessions
@@ -185,6 +198,64 @@ On the `'streaming-json'` path with auto-approve, the adapter adds
`--always-approve --no-plan --no-auto-update`. Those flags keep Plan Mode and
the CLI update check from blocking a headless run.
+## Structured Output
+
+Pass `outputSchema` on `chat()`. Grok Build has no native schema flag. The adapter adds the JSON Schema to the prompt (ACP and `streaming-json`) and parses the last assistant text. Tool activity still streams. The object arrives as `structured-output.complete`.
+
+```ts
+import { chat } from "@tanstack/ai"
+import { grokBuildText } from "@tanstack/ai-grok-build"
+import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+const sandbox = defineSandbox({
+ id: "repo-report",
+ provider: dockerSandbox({ image: "node:22" }),
+})
+
+const report = await chat({
+ adapter: grokBuildText("grok-build"),
+ messages: [{ role: "user", content: "Review this repo." }],
+ outputSchema: Report,
+ middleware: [withSandbox(sandbox)],
+})
+
+report.summary
+```
+
+This path parses JSON from the last assistant message. If extract-only is the job, use `@tanstack/ai-grok`.
+
+On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
+
+```tsx
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+function ReportView() {
+ const { final, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/repo-report"),
+ outputSchema: Report,
+ })
+
+ if (isLoading) return
The agent is inspecting the repo.
+ if (!final) return null
+ return
{final.summary}
+}
+```
+
+Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
+
## Limitations
- **Requires a sandbox.** Always run it under `withSandbox(...)`; see the
diff --git a/docs/adapters/opencode.md b/docs/adapters/opencode.md
index 23ccbb453c..a6915d752e 100644
--- a/docs/adapters/opencode.md
+++ b/docs/adapters/opencode.md
@@ -175,7 +175,61 @@ const stream = chat({
## Structured Output
-`structuredOutput()` is best-effort: OpenCode's prompt API has no native JSON-schema channel, so the schema is embedded as a prompt instruction in a fresh, one-shot session and the final text is parsed (markdown fences are stripped when present). It works for finalization after a chat, but a plain provider adapter (e.g. `@tanstack/ai-openai`) is the better choice when structured extraction is the primary job — it's faster, deterministic, and doesn't spawn a harness.
+Pass `outputSchema` on `chat()`. OpenCode has no native schema flag. The adapter adds the JSON Schema to the prompt and parses the last assistant text (markdown fences are stripped). Tool activity still streams. The object arrives as `structured-output.complete`.
+
+```ts
+import { chat } from "@tanstack/ai"
+import { opencodeText } from "@tanstack/ai-opencode"
+import { defineSandbox, withSandbox } from "@tanstack/ai-sandbox"
+import { dockerSandbox } from "@tanstack/ai-sandbox-docker"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+const sandbox = defineSandbox({
+ id: "repo-report",
+ provider: dockerSandbox({ image: "node:22" }),
+})
+
+const report = await chat({
+ adapter: opencodeText("anthropic/claude-opus-4-5"),
+ messages: [{ role: "user", content: "Review this repo." }],
+ outputSchema: Report,
+ middleware: [withSandbox(sandbox)],
+})
+
+report.summary
+```
+
+This path parses JSON from the last assistant message. If extract-only is the job, use a model adapter such as `@tanstack/ai-openai`.
+
+On the client, pass the same schema to `useChat` and read `final`. `partial` stays empty until the end.
+
+```tsx
+import { fetchServerSentEvents, useChat } from "@tanstack/ai-react"
+import { z } from "zod"
+
+const Report = z.object({
+ summary: z.string(),
+ filesChanged: z.array(z.string()),
+})
+
+function ReportView() {
+ const { final, isLoading } = useChat({
+ connection: fetchServerSentEvents("/api/repo-report"),
+ outputSchema: Report,
+ })
+
+ if (isLoading) return
The agent is inspecting the repo.
+ if (!final) return null
+ return
{final.summary}
+}
+```
+
+Full walkthrough, including the client: [Harness Agents](../structured-outputs/harnesses).
## Limitations
diff --git a/docs/chat/structured-outputs.md b/docs/chat/structured-outputs.md
index 425dc56abe..4793bed491 100644
--- a/docs/chat/structured-outputs.md
+++ b/docs/chat/structured-outputs.md
@@ -17,5 +17,6 @@ The structured-outputs guide has moved to its own top-level section, split by wh
- **[Streaming UIs](../structured-outputs/streaming)** — `useChat({ outputSchema })` with `partial` and `final` populating a UI field by field.
- **[Multi-Turn Chat](../structured-outputs/multi-turn)** — every assistant turn carries its own typed `StructuredOutputPart`, history stays renderable, and `messages[i].parts.find(p => p.type === "structured-output").data` is typed by your schema.
- **[With Tools](../structured-outputs/with-tools)** — combining `outputSchema` with the agent loop, including pause/resume for server-tool approvals and client-tool invocations.
+- **[Harness Agents](../structured-outputs/harnesses)** — a coding agent in a sandbox inspects files, then returns a typed object.
> **Note:** This URL is kept for backward compatibility. New content lives under `/structured-outputs/*` — update existing bookmarks when you can.
diff --git a/docs/config.json b/docs/config.json
index 0dd0be3112..65677202e6 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -337,17 +337,19 @@
"label": "Overview",
"to": "structured-outputs/overview",
"addedAt": "2026-05-19",
- "updatedAt": "2026-06-10"
+ "updatedAt": "2026-08-18"
},
{
"label": "One-Shot Extraction",
"to": "structured-outputs/one-shot",
- "addedAt": "2026-05-19"
+ "addedAt": "2026-05-19",
+ "updatedAt": "2026-08-14"
},
{
"label": "Streaming UIs",
"to": "structured-outputs/streaming",
- "addedAt": "2026-05-19"
+ "addedAt": "2026-05-19",
+ "updatedAt": "2026-08-18"
},
{
"label": "Multi-Turn Chat",
@@ -358,7 +360,13 @@
"label": "With Tools",
"to": "structured-outputs/with-tools",
"addedAt": "2026-05-19",
- "updatedAt": "2026-07-08"
+ "updatedAt": "2026-08-14"
+ },
+ {
+ "label": "Harness Agents",
+ "to": "structured-outputs/harnesses",
+ "addedAt": "2026-08-14",
+ "updatedAt": "2026-08-18"
}
]
},
@@ -511,25 +519,30 @@
"label": "Overview",
"to": "sandbox/overview",
"addedAt": "2026-06-16",
- "updatedAt": "2026-08-12"
+ "updatedAt": "2026-08-18"
},
{
"label": "Quick Start",
"to": "sandbox/quick-start",
"addedAt": "2026-06-29",
- "updatedAt": "2026-08-12"
+ "updatedAt": "2026-08-18"
},
{
"label": "Providers",
"to": "sandbox/providers",
"addedAt": "2026-06-29",
- "updatedAt": "2026-08-12"
+ "updatedAt": "2026-08-18"
},
{
"label": "Harnesses",
"to": "sandbox/harnesses",
"addedAt": "2026-06-30",
- "updatedAt": "2026-08-04"
+ "updatedAt": "2026-08-18"
+ },
+ {
+ "label": "Harness Auth",
+ "to": "sandbox/auth",
+ "addedAt": "2026-08-18"
},
{
"label": "Workspace",
@@ -866,30 +879,31 @@
"label": "Claude Code",
"to": "adapters/claude-code",
"addedAt": "2026-06-12",
- "updatedAt": "2026-06-30"
+ "updatedAt": "2026-08-18"
},
{
"label": "Codex",
"to": "adapters/codex",
"addedAt": "2026-06-12",
- "updatedAt": "2026-08-12"
+ "updatedAt": "2026-08-18"
},
{
"label": "OpenCode",
"to": "adapters/opencode",
"addedAt": "2026-06-12",
- "updatedAt": "2026-06-30"
+ "updatedAt": "2026-08-17"
},
{
"label": "Grok Build",
"to": "adapters/grok-build",
"addedAt": "2026-06-29",
- "updatedAt": "2026-08-12"
+ "updatedAt": "2026-08-18"
},
{
"label": "ACP-Compatible",
"to": "adapters/acp-compatible",
- "addedAt": "2026-06-30"
+ "addedAt": "2026-06-30",
+ "updatedAt": "2026-08-18"
},
{
"label": "Amazon Bedrock",
diff --git a/docs/sandbox/auth.md b/docs/sandbox/auth.md
new file mode 100644
index 0000000000..5f0ef5b5ee
--- /dev/null
+++ b/docs/sandbox/auth.md
@@ -0,0 +1,165 @@
+---
+title: Harness Auth
+id: sandbox-auth
+description: "Pick host login or an API key for a coding-agent harness. The same local-process sandbox can be your laptop or a CI runner."
+---
+
+Your laptop already has `grok login`, `claude login`, or `codex login`. A GitHub
+runner has no browser login. It only has an API key. Both can use
+`localProcessSandbox()`.
+
+Set `authMode` on the adapter. The sandbox type does not pick the credentials.
+The default is `'api-key'`. Most harnesses run in Docker or a cloud sandbox.
+Those have no host CLI login.
+
+- `'api-key'` (default): inject the key and use it.
+- `'host'`: use the CLI login on the machine. Do not inject an API key into
+ that process.
+
+Pass `authMode` in one of two places:
+
+- Adapter factory: `grokBuildText('composer-2.5', { authMode: 'host' })`
+- One call: `chat({ modelOptions: { authMode: 'host' } })`
+
+## Host login
+
+Use this when the machine already ran `grok login`, `claude login`, or
+`codex login`.
+
+```ts
+import { chat } from '@tanstack/ai'
+import { grokBuildText } from '@tanstack/ai-grok-build'
+import { defineSandbox, defineWorkspace, withSandbox } from '@tanstack/ai-sandbox'
+import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
+import { messages, threadId } from './chat-context'
+
+const sandbox = defineSandbox({
+ id: 'repo-agent',
+ provider: localProcessSandbox({
+ scrubEnv: ['XAI_API_KEY', 'GROK_API_KEY'],
+ }),
+ workspace: defineWorkspace({
+ source: { type: 'local', path: '/abs/path/to/repo' },
+ }),
+})
+
+const stream = chat({
+ threadId,
+ adapter: grokBuildText('composer-2.5', { authMode: 'host' }),
+ messages,
+ middleware: [withSandbox(sandbox)],
+})
+```
+
+`scrubEnv` removes keys the host process inherited. If the CLI sees
+`XAI_API_KEY`, it can prefer that key over your login.
+
+## API key
+
+This is the default. Use it on a runner, in Docker, or on any machine that
+has no CLI login. You can omit `authMode`.
+
+```ts
+import { chat } from '@tanstack/ai'
+import { grokBuildText } from '@tanstack/ai-grok-build'
+import {
+ createSecrets,
+ defineSandbox,
+ defineWorkspace,
+ githubRepo,
+ withSandbox,
+} from '@tanstack/ai-sandbox'
+import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
+import { messages, threadId } from './chat-context'
+
+const sandbox = defineSandbox({
+ id: 'repo-agent',
+ provider: dockerSandbox({ image: 'node:22' }),
+ workspace: defineWorkspace({
+ source: githubRepo({ repo: 'owner/app' }),
+ secrets: createSecrets({
+ XAI_API_KEY: process.env.XAI_API_KEY ?? '',
+ }),
+ }),
+})
+
+const stream = chat({
+ threadId,
+ adapter: grokBuildText('composer-2.5'),
+ messages,
+ middleware: [withSandbox(sandbox)],
+})
+```
+
+## Each harness
+
+| Adapter | `authMode: 'api-key'` (default) | `authMode: 'host'` |
+| --- | --- | --- |
+| [Grok Build](../adapters/grok-build) | `XAI_API_KEY` | `grok login` |
+| [Claude Code](../adapters/claude-code) | `ANTHROPIC_API_KEY` | `claude login` |
+| [Codex](../adapters/codex) | `CODEX_API_KEY` | `codex login` |
+| [ACP-Compatible](../adapters/acp-compatible) | set `authMethodId` (for Grok, `xai.api_key`) | skip ACP `authenticate` |
+
+OpenCode still reads `OPENAI_API_KEY` from the process env. It has no `authMode`
+flag.
+
+## Client and server
+
+The React chat example exposes the same choice on `/repo-report`. The client
+sends `authMode`. The server builds the adapter with that value.
+
+Client:
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+
+function Report() {
+ const { sendMessage } = useChat({
+ connection: fetchServerSentEvents('/api/sandbox-repo-report'),
+ forwardedProps: { authMode: 'api-key' },
+ })
+
+ return (
+
+ )
+}
+```
+
+Server:
+
+```ts
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { grokBuildText } from '@tanstack/ai-grok-build'
+import { withSandbox } from '@tanstack/ai-sandbox'
+import { sandbox } from './sandbox'
+
+export async function POST(request: Request) {
+ const body: unknown = await request.json()
+ const forwarded =
+ typeof body === 'object' &&
+ body !== null &&
+ 'forwardedProps' in body &&
+ typeof body.forwardedProps === 'object' &&
+ body.forwardedProps !== null
+ ? body.forwardedProps
+ : {}
+ const authMode =
+ 'authMode' in forwarded && forwarded.authMode === 'host'
+ ? 'host'
+ : 'api-key'
+
+ const stream = chat({
+ adapter: grokBuildText('composer-2.5', { authMode }),
+ messages: [{ role: 'user', content: 'Report on this repo' }],
+ stream: true,
+ middleware: [withSandbox(sandbox)],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
+```
+
+See [Harnesses](./harnesses) for which adapter to pick, and [Providers](./providers)
+for `scrubEnv` on local-process.
diff --git a/docs/sandbox/harnesses.md b/docs/sandbox/harnesses.md
index 21da0c4b4a..a24081697a 100644
--- a/docs/sandbox/harnesses.md
+++ b/docs/sandbox/harnesses.md
@@ -18,12 +18,17 @@ fast at the call site unless a sandbox is provided via `withSandbox(...)`.
Each agent has its own package with curated per-model metadata. Pass the adapter
to `chat({ adapter })` and run it under any provider.
-| Harness | Package | Adapter | Auth env |
+| Harness | Package | Adapter | Auth |
| --- | --- | --- | --- |
-| [Grok Build](../adapters/grok-build) | `@tanstack/ai-grok-build` | `grokBuildText` | `XAI_API_KEY` (or grok.com login on local-process) |
-| [Claude Code](../adapters/claude-code) | `@tanstack/ai-claude-code` | `claudeCodeText` | `ANTHROPIC_API_KEY` (or `claude login`) |
-| [Codex](../adapters/codex) | `@tanstack/ai-codex` | `codexText` | `CODEX_API_KEY` (or `OPENAI_API_KEY`) |
-| [OpenCode](../adapters/opencode) | `@tanstack/ai-opencode` | `opencodeText` | `OPENAI_API_KEY` (model-dependent) |
+| [Grok Build](../adapters/grok-build) | `@tanstack/ai-grok-build` | `grokBuildText` | Default `'api-key'` (`XAI_API_KEY`). `'host'` uses `grok login`. |
+| [Claude Code](../adapters/claude-code) | `@tanstack/ai-claude-code` | `claudeCodeText` | Default `'api-key'` (`ANTHROPIC_API_KEY`). `'host'` uses `claude login`. |
+| [Codex](../adapters/codex) | `@tanstack/ai-codex` | `codexText` | Default `'api-key'` (`CODEX_API_KEY`). `'host'` uses `codex login`. |
+| [OpenCode](../adapters/opencode) | `@tanstack/ai-opencode` | `opencodeText` | `OPENAI_API_KEY` from the process env. No `authMode` flag. |
+| [ACP-Compatible](../adapters/acp-compatible) | `@tanstack/ai-acp` | `acpCompatible` / `acpCompatibleText` | Default `'api-key'` uses `authMethodId`. `'host'` skips ACP `authenticate`. |
+
+The provider is where the agent runs, not how it signs in. The default
+`authMode` is `'api-key'`. Set `'host'` when the machine already has a CLI
+login. See [Harness Auth](./auth).
```ts
import { chat } from '@tanstack/ai'
@@ -39,6 +44,10 @@ const stream = chat({
})
```
+## Typed report from a harness
+
+If you want a typed object after the agent inspects the repo, pass `outputSchema` on the same `chat()` call. Dedicated adapters and `acpCompatible` honor it. See [Harness Agents](../structured-outputs/harnesses).
+
## Harness output can go to a journal
`grokBuildText`, `claudeCodeText`, and `codexText` can stop holding the agent's
@@ -120,6 +129,7 @@ and protocol coverage). For which agents you can plug in, browse the official
## Where to go next
+- **[Harness Auth](./auth)**: pick host login or an API key. The sandbox type does not pick this.
- **[Providers](./providers)**: where the harness runs (local, Docker, Daytona, Vercel, Sprites).
- **[The Run Journal](./journal)**: how a run's output survives the host that started it.
- **[Takeover & Detached Runs](./takeover)**: detach on disconnect, and the three exports a harness adapter implements to support attach.
diff --git a/docs/sandbox/overview.md b/docs/sandbox/overview.md
index 79259a6b84..cf8f3064f4 100644
--- a/docs/sandbox/overview.md
+++ b/docs/sandbox/overview.md
@@ -66,6 +66,10 @@ any one without touching the others.
hooks) into a reusable definition. `withSandbox(definition)` is the `chat()`
middleware that turns it on for a run.
+The provider is where the agent runs, not how it signs in. The default
+`authMode` is `'api-key'`. Set `'host'` when the machine already has a CLI
+login. See [Harness Auth](./auth).
+
### How a run executes
```txt
@@ -112,6 +116,8 @@ After that, pick the piece you need:
(`sbxSandbox`), Daytona, Vercel, Sprites, and what each one can do.
- [Harnesses](./harnesses): which agent runs. Grok Build, Claude Code, Codex,
OpenCode, or any ACP agent.
+- [Harness Auth](./auth): host CLI login or an API key. The sandbox type does
+ not pick this.
- [Workspace](./workspace): the source repo, clone depth, and setup commands.
- [Tools](./tools): bridge your app's own tools into the in-sandbox agent.
- [Policy](./policy): allow, ask or deny guardrails on what the agent may run.
@@ -127,7 +133,7 @@ build an adapter.
## Try it
-Two runnable demos:
+Three runnable demos:
- [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web):
a "build me an app" agent on Docker with durable runs wired. It scaffolds an app,
@@ -135,3 +141,7 @@ Two runnable demos:
and a closed tab, and Stop is a real cancel.
- [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare):
the same idea at the edge, with the harness picked per run from the UI.
+- [`examples/ts-react-chat`](https://github.com/TanStack/ai/tree/main/examples/ts-react-chat)
+ at `/repo-report`: clone `TanStack/ai`, pick the harness, pick Auth
+ (`host` or `api-key`), and read a typed report from `useChat().final`. See
+ [Harness Agents](../structured-outputs/harnesses).
diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md
index be63e7f340..5c59e605ca 100644
--- a/docs/sandbox/providers.md
+++ b/docs/sandbox/providers.md
@@ -61,30 +61,36 @@ const dev = localProcessSandbox()
- **Isolation:** none. The harness runs directly on your host, inheriting your
host environment. Use it for trusted or dev work only. There is no boundary
between the agent and your machine.
-- **Auth / env:** inherits the host environment. No API key injection is required
- if your host CLI is already logged in.
+- **Auth / env:** inherits the host environment. Set `authMode` on the harness
+ (`'host'` or `'api-key'`). The provider does not pick this. See
+ [Harness Auth](./auth).
- **Snapshot / resume:** no snapshots and no durable resume-by-id; each run
re-creates and re-bootstraps under the same identity. The snapshot step is
skipped silently (see [Capabilities](#capabilities)).
-### Use a host CLI's own auth (`scrubEnv`)
+### Host login vs API key (`scrubEnv`)
-Because `localProcessSandbox` runs the harness on your host, it inherits your host
-environment, including any API keys exported there. Use `scrubEnv` to remove
-variables before spawning, so the host CLI falls back to its own logged-in
-session instead of billing the API. For example, drop `XAI_API_KEY` so Grok Build
-uses your **grok.com login** (the same trick works for Claude Code with
-`ANTHROPIC_API_KEY` → `claude login`):
+The provider is where the agent runs, not how it signs in. The default
+`authMode` is `'api-key'`. Set `'host'` when the machine already has a CLI
+login. A local-process run can be your laptop or a GitHub runner. See
+[Harness Auth](./auth).
+
+`localProcessSandbox` inherits the host environment, including any API keys
+exported there. If you set `authMode: 'host'`, pass `scrubEnv` so those keys
+do not override the CLI login:
```ts
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
-const hostLogin = localProcessSandbox({ scrubEnv: ['XAI_API_KEY'] })
+const hostLogin = localProcessSandbox({
+ scrubEnv: ['XAI_API_KEY', 'GROK_API_KEY'],
+})
```
-> Only local-process can do this. It is the only provider that runs your host
-> CLI. Isolated and cloud providers have no host login, so they always use an
-> injected API key (supplied as a workspace secret).
+If the same local-process sandbox runs on a CI machine, set
+`authMode: 'api-key'`. Then inject the key as a workspace secret. Isolated and
+cloud providers have no host CLI login. Use `authMode: 'api-key'` and
+workspace secrets there.
### Windows process teardown (`logger`)
diff --git a/docs/sandbox/quick-start.md b/docs/sandbox/quick-start.md
index fc9050e117..0da2a70bc2 100644
--- a/docs/sandbox/quick-start.md
+++ b/docs/sandbox/quick-start.md
@@ -135,23 +135,42 @@ npm i @tanstack/ai-sandbox-local-process
```
```ts
+import { chat } from '@tanstack/ai'
+import { grokBuildText } from '@tanstack/ai-grok-build'
+import {
+ defineSandbox,
+ defineWorkspace,
+ githubRepo,
+ withSandbox,
+} from '@tanstack/ai-sandbox'
import { localProcessSandbox } from '@tanstack/ai-sandbox-local-process'
-import { defineSandbox, defineWorkspace, githubRepo } from '@tanstack/ai-sandbox'
+import { messages } from './chat-context'
export const repoSandbox = defineSandbox({
id: 'bug-fixer',
- provider: localProcessSandbox(),
+ provider: localProcessSandbox({
+ scrubEnv: ['XAI_API_KEY', 'GROK_API_KEY'],
+ }),
workspace: defineWorkspace({
source: githubRepo({ repo: 'owner/buggy-app' }),
setup: ['corepack enable', 'pnpm install'],
}),
lifecycle: { reuse: 'thread' },
})
+
+const stream = chat({
+ adapter: grokBuildText('composer-2.5', { authMode: 'host' }),
+ messages,
+ middleware: [withSandbox(repoSandbox)],
+})
```
-Because local-process inherits your host environment, you can drop the
-`XAI_API_KEY` secret and let Grok Build fall back to your grok.com login. For that
-(and for Daytona, Vercel, Sprites, and Cloudflare runtimes), see [Providers](./providers).
+Set `authMode: 'host'` so the adapter uses `grok login`. `scrubEnv` removes
+keys the host process inherited. Those keys would override the login.
+
+A local-process run can also be a CI runner. That machine has no browser
+login. Set `authMode: 'api-key'`. Then inject `XAI_API_KEY` as a workspace
+secret. The sandbox type does not pick this. See [Harness Auth](./auth).
## Docker Sandboxes microVM (sbx)
diff --git a/docs/structured-outputs/harnesses.md b/docs/structured-outputs/harnesses.md
new file mode 100644
index 0000000000..543813bb8a
--- /dev/null
+++ b/docs/structured-outputs/harnesses.md
@@ -0,0 +1,218 @@
+---
+title: Harness Structured Output
+id: structured-outputs-harnesses
+order: 6
+description: "Ask a coding agent in a sandbox to inspect a repo, then read a typed object from chat({ outputSchema }). Works with Claude Code, Codex, OpenCode, Grok Build, and acpCompatible."
+keywords:
+ - tanstack ai
+ - structured outputs
+ - harness
+ - claude code
+ - codex
+ - opencode
+ - grok build
+ - outputSchema
+ - sandbox
+---
+
+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:
+
+- [Claude Code](../adapters/claude-code)
+- [Codex](../adapters/codex)
+- [OpenCode](../adapters/opencode)
+- [Grok Build](../adapters/grok-build)
+- [ACP-Compatible](../adapters/acp-compatible) (`acpCompatible`)
+
+If you only extract JSON from a prompt and you do not need a sandbox, use [One-Shot Extraction](./one-shot) with an HTTP adapter.
+
+## Define the schema
+
+```typescript group=harness-output
+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.
+
+```typescript group=harness-output
+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](../adapters/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 group=harness-output
+import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+
+function RepoReport() {
+ const { messages, sendMessage, isLoading, final } = useChat({
+ connection: fetchServerSentEvents("/api/repo-report"),
+ outputSchema: ReportSchema,
+ });
+
+ return (
+ <>
+
+ {messages.map((message) => (
+
: 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](./streaming) for the `partial` / `final` shape.
+
+## 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 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`.
+
+## 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-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.
+
+```typescript group=harness-output
+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`](https://github.com/TanStack/ai/tree/main/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](../sandbox/auth).
diff --git a/docs/structured-outputs/one-shot.md b/docs/structured-outputs/one-shot.md
index 183181b88f..2e43cb4684 100644
--- a/docs/structured-outputs/one-shot.md
+++ b/docs/structured-outputs/one-shot.md
@@ -17,7 +17,7 @@ You have unstructured input — a paragraph of text, a freeform user prompt, the
By the end of this guide you'll have a working `chat({ outputSchema })` call returning a fully-typed result, know how to describe fields so the model fills them correctly, and have a pattern for handling validation errors.
-> **Note:** If you want to stream the result field-by-field into a UI, you want [Streaming UIs](./streaming) instead. If you want users to iterate on the object across multiple turns, you want [Multi-Turn Chat](./multi-turn). This page is for the single-extraction case.
+> **Note:** If you want to stream the result field-by-field into a UI, you want [Streaming UIs](./streaming) instead. If you want users to iterate on the object across multiple turns, you want [Multi-Turn Chat](./multi-turn). If the model must inspect files in a sandbox first, you want [Harness Agents](./harnesses). This page is for the single-extraction case.
## Basic Usage
diff --git a/docs/structured-outputs/overview.md b/docs/structured-outputs/overview.md
index 18edaac6e5..79d1ad1c3f 100644
--- a/docs/structured-outputs/overview.md
+++ b/docs/structured-outputs/overview.md
@@ -57,8 +57,10 @@ Every adapter handles structured output through its provider's native API:
| Google Gemini | `responseSchema` |
| Ollama | JSON mode with schema |
| OpenRouter / Grok / Groq | `response_format` with `json_schema` |
+| Claude Code / Codex | Native schema flag on the same harness turn (`--json-schema` / `--output-schema`) |
+| OpenCode / Grok Build / ACP compatible | Same-turn prompt-and-parse |
-The provider-specific details are handled for you — the same `chat({ outputSchema })` call works across all of them.
+The provider-specific details are handled for you. The same `chat({ outputSchema })` call works across all of them. For a coding agent in a sandbox, see [Harness Agents](./harnesses).
### Anthropic schema complexity limits
@@ -75,7 +77,7 @@ Anthropic's exact limits change over time and aren't all published, so we delibe
## Which page do I read?
-Pick the journey that matches what you're building. The four guides under "Structured Outputs" cover non-overlapping use cases — read the one that fits, not all of them.
+Pick the journey that matches what you're building. The guides under Structured Outputs cover separate use cases. Read the one that fits.
| You want to… | Read |
|---|---|
@@ -83,6 +85,7 @@ Pick the journey that matches what you're building. The four guides under "Struc
| Build a UI that fills in field-by-field as the model streams (progressive form, live card, typewriter preview) | [Streaming UIs](./streaming) |
| Let users iterate on a structured object across multiple turns — each turn produces a new typed object and history stays renderable | [Multi-Turn Chat](./multi-turn) |
| Combine structured output with tool calls (agent loop that runs tools first, then returns a typed object) | [With Tools](./with-tools) |
+| Ask a coding agent in a sandbox to inspect files, then return a typed object | [Harness Agents](./harnesses) |
The streaming and multi-turn paths both build on `useChat({ outputSchema })`. The "with tools" path layers on top of either. Pick the one that describes your shipping shape — start there, follow the cross-links when you need a piece of another story.
diff --git a/docs/structured-outputs/streaming.md b/docs/structured-outputs/streaming.md
index cd37c21c7a..01c3f5bb77 100644
--- a/docs/structured-outputs/streaming.md
+++ b/docs/structured-outputs/streaming.md
@@ -93,7 +93,7 @@ What the hook does for you:
- **`partial`** is `DeepPartial>` — every property optional, every nested array element optional. Updated from `TEXT_MESSAGE_CONTENT` deltas via the runtime's partial-JSON parser. The hook derives it from the latest assistant message's `structured-output` part (see [Multi-Turn Chat](./multi-turn) for why that distinction matters), so it reads `{}` between `sendMessage()` and the first chunk without any extra reset state.
- **`final`** is `z.infer | null` — the validated terminal payload from the `structured-output.complete` event. `null` until the run completes successfully.
- **`outputSchema`** is used purely for client-side TypeScript inference. Validation still runs on the server against the schema you pass to `chat({ outputSchema })` on the server route — the client doesn't re-validate.
-- The same shape works for **non-streaming adapters too**. If an adapter (Anthropic, Gemini, Ollama) returns a single `structured-output.complete` event with no incremental deltas, `partial` stays `{}` and `final` populates when the event arrives. Same consumer code.
+- The same shape works for **non-streaming adapters too**. If an adapter (Anthropic, Gemini, Ollama) returns a single `structured-output.complete` event with no incremental deltas, `partial` stays `{}` and `final` populates when the event arrives. Same consumer code. Claude Code and Codex emit `structured-output.complete` from the harness event. OpenCode, Grok Build, and `acpCompatible` parse the last assistant text at the end. In both cases `partial` stays empty until `final` is set. See [Harness Agents](./harnesses).
`outputSchema` is optional: omit it and `useChat` returns its standard shape without `partial` / `final`.
@@ -109,26 +109,45 @@ What the hook does for you:
| `TEXT_MESSAGE_CONTENT` (with `outputSchema` set) | `StructuredOutputPart` on the assistant message — the JSON deltas accumulate into `part.raw` and the progressive parse populates `part.partial` |
| `TEXT_MESSAGE_CONTENT` (no `outputSchema`) | `TextPart` on the assistant message |
-So render reasoning and tool calls the same way you'd render them in a normal chat UI:
+So render reasoning, tool calls, and the typed object from `messages[].parts`. `final` and `partial` are shortcuts for the latest `structured-output` part only.
```tsx ignore
-const last = messages.at(-1);
-
return (
<>
- {last?.parts.map((part, i) => {
- if (part.type === "thinking") return ;
- if (part.type === "tool-call") return ;
- // The structured-output part is rendered separately via the
- // `partial` / `final` sugar below — no need to walk it here.
- return null;
- })}
-
-
+ {messages.map((message) => (
+
+ Pick Claude Code, Grok Build, ACP compatible, or Codex, pick an
+ agent, then Run. The sandbox clones {REPORT_REPO} and the typed
+ report lands here.
+
+ ) : null}
+
+
+
+ )
+}
diff --git a/examples/ts-react-chat/src/sandbox-triage-options.ts b/examples/ts-react-chat/src/sandbox-triage-options.ts
index b75905eab0..bf2cb0bcad 100644
--- a/examples/ts-react-chat/src/sandbox-triage-options.ts
+++ b/examples/ts-react-chat/src/sandbox-triage-options.ts
@@ -9,7 +9,7 @@
*/
// Pure string-literal types — re-exported by sandbox-triage.ts (single source of truth here).
-export type HarnessName = 'claude-code' | 'codex' | 'opencode' | 'grok'
+export type HarnessName = 'claude-code' | 'codex' | 'opencode' | 'grok' | 'acp'
export type ProviderName = 'docker' | 'local' | 'vercel' | 'daytona'
export type GrokBuildModel = 'grok-build-0.1' | 'composer-2.5'
export type GrokBuildProtocol = 'acp' | 'streaming-json'
@@ -37,6 +37,7 @@ export interface PickerSpec {
export const HARNESSES: Record = {
grok: { label: 'Grok Build' },
+ acp: { label: 'ACP compatible (Grok)' },
'claude-code': { label: 'Claude Code' },
codex: { label: 'Codex' },
opencode: { label: 'OpenCode' },
diff --git a/examples/ts-react-chat/src/sandbox-triage.test.ts b/examples/ts-react-chat/src/sandbox-triage.test.ts
index e820c73a9a..77d204d68b 100644
--- a/examples/ts-react-chat/src/sandbox-triage.test.ts
+++ b/examples/ts-react-chat/src/sandbox-triage.test.ts
@@ -50,8 +50,9 @@ describe('parseVerdict', () => {
})
describe('registries', () => {
- it('has 4 harnesses and 4 providers with labels + required env arrays', () => {
+ it('has 5 harnesses and 4 providers with labels + required env arrays', () => {
expect(Object.keys(HARNESSES).sort()).toEqual([
+ 'acp',
'claude-code',
'codex',
'grok',
@@ -76,6 +77,23 @@ describe('registries', () => {
expect(isProvider(42)).toBe(false)
})
+ it('missingEnv skips harness keys when authMode is host', () => {
+ delete process.env.XAI_API_KEY
+ delete process.env.GROK_API_KEY
+ expect(missingEnv('grok', 'docker', 'host')).not.toContain('XAI_API_KEY')
+ expect(missingEnv('grok', 'local', 'api-key')).toContain(
+ 'XAI_API_KEY (or GROK_API_KEY)',
+ )
+ })
+
+ it('missingEnv requires harness keys when authMode is omitted', () => {
+ delete process.env.XAI_API_KEY
+ delete process.env.GROK_API_KEY
+ expect(missingEnv('grok', 'local')).toContain(
+ 'XAI_API_KEY (or GROK_API_KEY)',
+ )
+ })
+
it('missingEnv reports unset required vars', () => {
delete process.env.ANTHROPIC_API_KEY
delete process.env.DAYTONA_API_KEY
diff --git a/examples/ts-react-chat/src/sandbox-triage.ts b/examples/ts-react-chat/src/sandbox-triage.ts
index 5bf1259ab1..236dfc0219 100644
--- a/examples/ts-react-chat/src/sandbox-triage.ts
+++ b/examples/ts-react-chat/src/sandbox-triage.ts
@@ -1,3 +1,4 @@
+import { acpCompatibleText } from '@tanstack/ai-acp'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { codexText } from '@tanstack/ai-codex'
import {
@@ -111,7 +112,14 @@ function npmGlobalCli(spec: string, verify: string): string {
export const HARNESSES: Record = {
'claude-code': {
label: 'Claude Code',
- makeAdapter: () => claudeCodeText('sonnet'),
+ // Headless `-p` cannot answer permission prompts. Isolated sandboxes and
+ // this trusted local demo both need bypassPermissions, same as Codex
+ // `danger-full-access`. Do not set CLAUDE_CODE_SANDBOXED here: that marker
+ // is only for a real isolation boundary, which the adapter sets itself.
+ makeAdapter: () =>
+ claudeCodeText('sonnet', {
+ permissionMode: 'bypassPermissions',
+ }),
installCommand: npmGlobalCli(
'@anthropic-ai/claude-code',
'claude --version',
@@ -173,12 +181,49 @@ export const HARNESSES: Record = {
},
exposePort: 2419,
},
+ acp: {
+ label: 'ACP compatible (Grok)',
+ makeAdapter: () =>
+ acpCompatibleText('composer-2.5', {
+ name: 'acp',
+ command: ({ model }) =>
+ `grok agent -m '${model}' --always-approve stdio`,
+ permissionMode: 'bypassPermissions',
+ }),
+ installCommand: GROK_CLI_INSTALL_COMMAND,
+ requiredEnv: ['XAI_API_KEY'],
+ envCheck: () =>
+ process.env.XAI_API_KEY || process.env.GROK_API_KEY
+ ? []
+ : ['XAI_API_KEY (or GROK_API_KEY)'],
+ sandboxSecrets: (): Record => {
+ const key = process.env.XAI_API_KEY ?? process.env.GROK_API_KEY
+ return key ? { XAI_API_KEY: key } : {}
+ },
+ },
}
+export type HarnessAuthMode = 'host' | 'api-key'
+
export interface GrokHarnessOptions {
model?: GrokBuildModel
protocol?: GrokBuildProtocol
transport?: GrokTransport
+ authMode?: HarnessAuthMode
+}
+
+function harnessAuthKeys(harness: HarnessName): Array {
+ switch (harness) {
+ case 'grok':
+ case 'acp':
+ return ['XAI_API_KEY', 'GROK_API_KEY']
+ case 'claude-code':
+ return ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN']
+ case 'codex':
+ return ['CODEX_API_KEY']
+ case 'opencode':
+ return ['OPENAI_API_KEY']
+ }
}
/** Build the adapter for a harness run, including per-run Grok protocol options. */
@@ -187,10 +232,33 @@ export function buildHarnessAdapter(
provider: ProviderName,
grokOptions?: GrokHarnessOptions,
): AnyTextAdapter {
+ const authMode = grokOptions?.authMode
if (harness === 'grok') {
return grokBuildText(grokOptions?.model ?? 'composer-2.5', {
protocol: grokOptions?.protocol ?? 'acp',
transport: grokOptions?.transport ?? 'auto',
+ ...(authMode !== undefined && { authMode }),
+ })
+ }
+ if (harness === 'acp') {
+ return acpCompatibleText('composer-2.5', {
+ name: 'acp',
+ command: ({ model }) => `grok agent -m '${model}' --always-approve stdio`,
+ permissionMode: 'bypassPermissions',
+ ...(authMode !== undefined && { authMode }),
+ ...(authMode === 'api-key' && { authMethodId: 'xai.api_key' }),
+ })
+ }
+ if (harness === 'claude-code') {
+ return claudeCodeText('sonnet', {
+ permissionMode: 'bypassPermissions',
+ ...(authMode !== undefined && { authMode }),
+ })
+ }
+ if (harness === 'codex') {
+ return codexText('gpt-5.5', {
+ sandboxMode: 'danger-full-access',
+ ...(authMode !== undefined && { authMode }),
})
}
return HARNESSES[harness].makeAdapter(provider)
@@ -282,17 +350,15 @@ export function usesSubscription(
export function missingEnv(
harness: HarnessName,
provider: ProviderName,
+ authMode?: HarnessAuthMode,
): Array {
- // local-process runs the agent on the host with the host's OWN auth — an env
- // API key, or a `claude login`/`codex login` — so the example requires no key
- // for it. Sandboxed providers must have a key injected, so it's required.
const harnessSpec = HARNESSES[harness]
- const harnessMissing =
- provider === 'local'
- ? []
- : harnessSpec.envCheck
- ? harnessSpec.envCheck()
- : harnessSpec.requiredEnv.filter((key) => !process.env[key])
+ const hostAuth = authMode === 'host'
+ const harnessMissing = hostAuth
+ ? []
+ : harnessSpec.envCheck
+ ? harnessSpec.envCheck()
+ : harnessSpec.requiredEnv.filter((key) => !process.env[key])
const spec = PROVIDERS[provider]
const providerMissing = spec.envCheck
? spec.envCheck()
@@ -381,28 +447,25 @@ export function buildSandbox(opts: {
keepAlive?: boolean
/** Local Claude Code only: use the host's subscription login instead of an API key. */
useSubscription?: boolean
+ /** Host login vs API key. Not inferred from the sandbox provider. */
+ authMode?: HarnessAuthMode
}): SandboxDefinition {
const harness = HARNESSES[opts.harness]
- const subscription = usesSubscription(
- opts.harness,
- opts.provider,
- opts.useSubscription,
- )
+ const authMode =
+ opts.authMode ??
+ (usesSubscription(opts.harness, opts.provider, opts.useSubscription)
+ ? 'host'
+ : 'api-key')
+ const hostAuth = authMode === 'host'
- // Subscription mode scrubs ANTHROPIC_API_KEY from the host claude's env (via the
- // provider's `scrubEnv` flag) so it falls back to the logged-in subscription.
- // Ports the in-sandbox CLI needs reachable from the host (e.g. opencode's serve port).
const ports = harness.exposePort !== undefined ? [harness.exposePort] : []
- const provider = subscription
- ? localProcessSandbox({ scrubEnv: ['ANTHROPIC_API_KEY'] })
- : PROVIDERS[opts.provider].make(ports)
+ const provider =
+ hostAuth && opts.provider === 'local'
+ ? localProcessSandbox({ scrubEnv: harnessAuthKeys(opts.harness) })
+ : PROVIDERS[opts.provider].make(ports)
- // Inject auth secrets only for sandboxed providers — local-process inherits the
- // host's own env (API key, or a `claude login`/`codex login`), so nothing to inject.
const secretEnv: Record = {}
- if (opts.provider !== 'local') {
- // Harness auth: a custom mapping (e.g. codex → CODEX_API_KEY) if provided,
- // otherwise inject whichever of its requiredEnv vars are set.
+ if (!hostAuth) {
if (harness.sandboxSecrets) {
Object.assign(secretEnv, harness.sandboxSecrets())
} else {
@@ -411,6 +474,8 @@ export function buildSandbox(opts: {
if (value) secretEnv[key] = value
}
}
+ }
+ if (opts.provider !== 'local') {
// Provider auth (e.g. DAYTONA_API_KEY) — used host-side, harmless in-sandbox.
for (const key of PROVIDERS[opts.provider].requiredEnv) {
const value = process.env[key]
diff --git a/knip.json b/knip.json
index 98ba9561f6..e6abef78b5 100644
--- a/knip.json
+++ b/knip.json
@@ -17,6 +17,7 @@
"**/*.test-d.ts",
"packages/ai-code-mode-snippets/test-cli/**",
".claude/worktrees/**",
+ "worktrees/**",
"packages/ai-openai/live-tests/**",
"packages/ai-openai/src/**/*.test.ts",
"packages/ai-openai/src/audio/audio-provider-options.ts",
diff --git a/packages/ai-acp/src/adapters/compatible.ts b/packages/ai-acp/src/adapters/compatible.ts
index 1cf84721b1..09d1ca798d 100644
--- a/packages/ai-acp/src/adapters/compatible.ts
+++ b/packages/ai-acp/src/adapters/compatible.ts
@@ -1,5 +1,11 @@
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
-import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
+import {
+ appendOutputSchemaInstruction,
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+ toRunErrorRawEvent,
+} from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
DurableAttachNotSupportedError,
@@ -133,10 +139,16 @@ export interface AcpCompatibleConfig<
skillsDir?: string
/** Extra environment variables for the harness process. */
env?: Record
+ /**
+ * `'api-key'` (default) uses {@link authMethodId} (or `modelOptions.authMethodId`).
+ * `'host'` skips ACP authenticate (use the CLI login on the machine).
+ * Not inferred from the sandbox.
+ */
+ authMode?: 'host' | 'api-key'
/**
* ACP auth method to select before the session starts, when the harness
* advertises one (e.g. `'pi-api-key'`). Overridable per call via
- * `modelOptions.authMethodId`.
+ * `modelOptions.authMethodId`. Ignored when {@link authMode} is `'host'`.
*/
authMethodId?: string
/** ACP permission policy. Defaults to `'bypassPermissions'`. */
@@ -185,7 +197,13 @@ export interface AcpCompatibleProviderOptions {
sessionId?: string
/** Per-call override of the harness working directory. */
cwd?: string
- /** Per-call override of the ACP auth method. */
+ /**
+ * `'api-key'` (default) uses {@link authMethodId}.
+ * `'host'` skips ACP authenticate.
+ * Not inferred from the sandbox.
+ */
+ authMode?: 'host' | 'api-key'
+ /** Per-call override of the ACP auth method. Ignored when authMode is `'host'`. */
authMethodId?: string
/** Per-call override of the ACP permission policy. */
permissionMode?: AcpPermissionMode
@@ -247,6 +265,14 @@ export class AcpCompatibleTextAdapter<
this.name = config.name
}
+ supportsCombinedToolsAndSchema(): boolean {
+ return true
+ }
+
+ combinedStructuredOutputSource(): 'event' {
+ return 'event'
+ }
+
private sandboxFrom(
options: TextOptions>,
): SandboxHandle {
@@ -446,8 +472,12 @@ export class AcpCompatibleTextAdapter<
modelOptions?.permissionMode ??
this.harness.permissionMode ??
'bypassPermissions'
+ const authMode =
+ modelOptions?.authMode ?? this.harness.authMode ?? 'api-key'
const authMethodId =
- modelOptions?.authMethodId ?? this.harness.authMethodId
+ authMode === 'host'
+ ? undefined
+ : (modelOptions?.authMethodId ?? this.harness.authMethodId)
const approvalRequests: Array = []
const permissionHandler = this.makePermissionHandler({
@@ -510,12 +540,18 @@ export class AcpCompatibleTextAdapter<
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
.map((p) => p.content)
.filter((c) => c.trim() !== '')
- const promptText = this.applySystemPrompts(
+ let promptText = this.applySystemPrompts(
systemPrompts,
session.resumed || sessionId === undefined
? resumePrompt
: this.buildPrompt(options.messages, undefined).prompt,
)
+ if (options.outputSchema) {
+ promptText = appendOutputSchemaInstruction(
+ promptText,
+ options.outputSchema,
+ )
+ }
session
.prompt(promptText)
@@ -529,7 +565,11 @@ export class AcpCompatibleTextAdapter<
})
.catch((error: unknown) => queue.fail(error))
- yield* mergeChunkStreams(
+ const wantsStructured = options.outputSchema !== undefined
+ let lastAssistantText = ''
+ let lastTextMessageId: string | undefined
+ let heldFinished: StreamChunk | undefined
+ for await (const chunk of mergeChunkStreams(
translateAcpStream(queue, {
model: this.model,
runId,
@@ -557,7 +597,36 @@ export class AcpCompatibleTextAdapter<
}),
}),
channel.stream,
- )
+ )) {
+ if (wantsStructured && chunk.type === EventType.RUN_FINISHED) {
+ heldFinished = chunk
+ continue
+ }
+ if (wantsStructured) {
+ if (chunk.type === EventType.TEXT_MESSAGE_START) {
+ lastAssistantText = ''
+ if (typeof chunk.messageId === 'string' && chunk.messageId !== '') {
+ lastTextMessageId = chunk.messageId
+ }
+ } else if (
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT &&
+ typeof chunk.delta === 'string'
+ ) {
+ lastAssistantText += chunk.delta
+ }
+ }
+ yield chunk
+ }
+
+ if (options.outputSchema) {
+ yield* this.emitParsedStructuredOutput(
+ lastAssistantText,
+ threadId,
+ runId,
+ lastTextMessageId,
+ )
+ }
+ if (heldFinished) yield heldFinished
// Surface any pending approval requests (interactive ask-policy actions
// awaiting a client decision); the client approves and re-runs to continue.
@@ -641,13 +710,54 @@ export class AcpCompatibleTextAdapter<
}
}
+ private *emitParsedStructuredOutput(
+ raw: string,
+ threadId: string,
+ runId: string,
+ messageId = this.generateId(),
+ ): Generator {
+ try {
+ const object = parseJsonFromAssistantText(raw)
+ yield structuredOutputStartChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ object,
+ raw,
+ })
+ } catch (error: unknown) {
+ const parserMessage =
+ error instanceof Error
+ ? error.message
+ : 'Failed to parse structured output'
+ const preview = raw.trim().slice(0, 200)
+ const message =
+ preview === '' ? parserMessage : `${parserMessage} Content: ${preview}`
+ yield {
+ type: EventType.RUN_ERROR,
+ model: this.model,
+ timestamp: Date.now(),
+ message,
+ code: 'structured-output-parse-failed',
+ error: { message, code: 'structured-output-parse-failed' },
+ }
+ }
+ }
+
structuredOutput(
_options: StructuredOutputOptions>,
): Promise> {
return Promise.reject(
new Error(
- `Structured output is not supported by the in-sandbox "${this.name}" ACP harness adapter. ` +
- 'Use a model adapter for structured output, or omit outputSchema.',
+ 'This harness honors outputSchema on chat() in the same turn. ' +
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
),
)
}
diff --git a/packages/ai-acp/tests/compatible.test.ts b/packages/ai-acp/tests/compatible.test.ts
index bb08954608..fe93f19824 100644
--- a/packages/ai-acp/tests/compatible.test.ts
+++ b/packages/ai-acp/tests/compatible.test.ts
@@ -27,7 +27,10 @@ const SDK_URL = pathToFileURL(require.resolve('@agentclientprotocol/sdk')).href
* params it received, and reports the given protocol version (defaults to the
* SDK's `PROTOCOL_VERSION`).
*/
-function fakeAcpAgent(protocolVersionExpr = 'PROTOCOL_VERSION'): string {
+function fakeAcpAgent(
+ protocolVersionExpr = 'PROTOCOL_VERSION',
+ reply = 'pong',
+): string {
return `
import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION } from ${JSON.stringify(SDK_URL)}
import { Readable, Writable } from 'node:stream'
@@ -54,7 +57,7 @@ new AgentSideConnection((conn) => ({
writeFileSync('acp-prompt.txt', JSON.stringify(params))
await conn.sessionUpdate({
sessionId: params.sessionId,
- update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'pong' } },
+ update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: ${JSON.stringify(reply)} } },
})
return { stopReason: 'end_turn' }
},
@@ -156,6 +159,15 @@ describe('acpCompatible config validation', () => {
/needs either a "command" or an "openTransport"/,
)
})
+
+ it('opts into combined event-source structured output', () => {
+ const adapter = acpCompatibleText('pi-fast', {
+ name: 'pi',
+ command: () => 'node fake-acp-agent.mjs',
+ })
+ expect(adapter.supportsCombinedToolsAndSchema()).toBe(true)
+ expect(adapter.combinedStructuredOutputSource()).toBe('event')
+ })
})
describe('acpCompatible in-sandbox adapter (stdio)', () => {
@@ -364,4 +376,83 @@ describe('acpCompatible in-sandbox adapter (stdio)', () => {
await sbx.destroy()
})
+
+ it('parses the last assistant text as structured output', async () => {
+ const sbx = await provider.create({})
+ await sbx.fs.write(
+ '/workspace/fake-acp-agent.mjs',
+ fakeAcpAgent('PROTOCOL_VERSION', '{"ok":true}'),
+ )
+
+ const chunks = await collect(
+ acpCompatibleText('pi-fast', {
+ name: 'pi',
+ command: () => 'node fake-acp-agent.mjs',
+ }).chatStream({
+ model: 'pi-fast',
+ messages: [{ role: 'user', content: 'report' }],
+ outputSchema: {
+ type: 'object',
+ properties: { ok: { type: 'boolean' } },
+ required: ['ok'],
+ },
+ logger: noopLogger,
+ capabilities: capabilityContextWith(sbx),
+ }),
+ )
+
+ const completeIndex = chunks.findIndex(
+ (chunk) =>
+ chunk.type === 'CUSTOM' &&
+ (chunk as { name?: string }).name === 'structured-output.complete',
+ )
+ const finishedIndex = chunks.findIndex(
+ (chunk) => chunk.type === 'RUN_FINISHED',
+ )
+ const complete = chunks[completeIndex] as
+ | { value?: { object?: unknown } }
+ | undefined
+ expect(complete?.value?.object).toEqual({ ok: true })
+ expect(completeIndex).toBeGreaterThan(-1)
+ expect(finishedIndex).toBeGreaterThan(completeIndex)
+
+ const prompt = JSON.parse(
+ await sbx.fs.read('/workspace/acp-prompt.txt'),
+ ) as { prompt?: Array<{ text?: string }> }
+ const promptText = prompt.prompt?.map((part) => part.text ?? '').join('')
+ expect(promptText).toMatch(/JSON object/i)
+
+ await sbx.destroy()
+ })
+
+ it('emits a parse error when the last assistant text is not JSON', async () => {
+ const sbx = await provider.create({})
+ await sbx.fs.write('/workspace/fake-acp-agent.mjs', FAKE_ACP_AGENT)
+
+ const chunks = await collect(
+ acpCompatibleText('pi-fast', {
+ name: 'pi',
+ command: () => 'node fake-acp-agent.mjs',
+ }).chatStream({
+ model: 'pi-fast',
+ messages: [{ role: 'user', content: 'report' }],
+ outputSchema: {
+ type: 'object',
+ properties: { ok: { type: 'boolean' } },
+ required: ['ok'],
+ },
+ logger: noopLogger,
+ capabilities: capabilityContextWith(sbx),
+ }),
+ )
+
+ const error = chunks.find((chunk) => chunk.type === 'RUN_ERROR') as {
+ code?: string
+ message?: string
+ }
+ expect(error?.code).toBe('structured-output-parse-failed')
+ expect(error?.message).toMatch(/pong/)
+
+ await sbx.destroy()
+ })
})
diff --git a/packages/ai-claude-code/src/adapters/claude-run-source.ts b/packages/ai-claude-code/src/adapters/claude-run-source.ts
new file mode 100644
index 0000000000..b12670db40
--- /dev/null
+++ b/packages/ai-claude-code/src/adapters/claude-run-source.ts
@@ -0,0 +1,66 @@
+/** Placeholder swapped for the schema JSON after the runner reads the files. */
+export const CLAUDE_JSON_SCHEMA_PLACEHOLDER = '__TANSTACK_SCHEMA__'
+
+/**
+ * Written into the sandbox and run with `node`.
+ *
+ * The shell only sees two filenames. The runner reads the argv array and the
+ * schema JSON from those files, then spawn()s claude so `--json-schema` is a
+ * real argv value (the CLI rejects a file path).
+ *
+ * On Windows, `claude` is often a `.cmd` shim. `spawn(cmd, args)` without a
+ * shell cannot find that shim, so we go through `sh` with `"$0"` / `"$@"`
+ * (git-bash is already on PATH in the local-process sandbox).
+ */
+export const CLAUDE_RUNNER_SOURCE = `import { spawn } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+
+const argvFile = process.argv[2]
+if (!argvFile) {
+ console.error('tanstack-claude-run: missing argv file')
+ process.exit(1)
+}
+
+const argv = JSON.parse(readFileSync(argvFile, 'utf8'))
+if (!Array.isArray(argv)) {
+ console.error('tanstack-claude-run: argv file must be a JSON array')
+ process.exit(1)
+}
+
+const schemaFile = process.argv[3]
+if (schemaFile) {
+ const schema = readFileSync(schemaFile, 'utf8')
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i] === ${JSON.stringify(CLAUDE_JSON_SCHEMA_PLACEHOLDER)}) argv[i] = schema
+ }
+}
+
+const [cmd, ...args] = argv
+if (typeof cmd !== 'string' || cmd === '') {
+ console.error('tanstack-claude-run: missing command')
+ process.exit(1)
+}
+
+const opts = {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: process.env,
+ windowsHide: true,
+}
+
+const child =
+ process.platform === 'win32'
+ ? spawn('sh', ['-c', 'exec "$0" "$@"', cmd, ...args], opts)
+ : spawn(cmd, args, opts)
+
+process.stdin.pipe(child.stdin)
+child.stdout.pipe(process.stdout)
+child.stderr.pipe(process.stderr)
+child.on('error', (error) => {
+ console.error(error)
+ process.exit(1)
+})
+child.on('exit', (code, signal) => {
+ if (signal) process.exit(1)
+ process.exit(code ?? 1)
+})
+`
diff --git a/packages/ai-claude-code/src/adapters/text.ts b/packages/ai-claude-code/src/adapters/text.ts
index 0725b46f67..3773c26ff8 100644
--- a/packages/ai-claude-code/src/adapters/text.ts
+++ b/packages/ai-claude-code/src/adapters/text.ts
@@ -1,5 +1,8 @@
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
-import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
+import {
+ appendOutputSchemaInstruction,
+ toRunErrorRawEvent,
+} from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
SandboxCapability,
@@ -26,6 +29,11 @@ import { buildPrompt } from '../messages/prompt'
import { translateSdkStream } from '../stream/translate'
import { mapPolicyToClaudeFlags } from './policy-map'
import { projectClaudeWorkspace } from './projection'
+import {
+ CLAUDE_JSON_SCHEMA_PLACEHOLDER,
+ CLAUDE_RUNNER_SOURCE,
+} from './claude-run-source'
+
import type { ClaudePolicyFlags } from './policy-map'
import type {
BridgeEventChannel,
@@ -89,6 +97,12 @@ export interface ClaudeCodeTextConfig {
streamPartials?: boolean
/** Extra environment variables for the claude process inside the sandbox. */
env?: Record
+ /**
+ * `'api-key'` (default) injects `ANTHROPIC_API_KEY`.
+ * `'host'` uses `claude login` and does not inject the key.
+ * Not inferred from the sandbox.
+ */
+ authMode?: 'host' | 'api-key'
/** Emit a `file.changed` CUSTOM event with the git diff after the run (default true). */
emitDiff?: boolean
}
@@ -98,6 +112,27 @@ function q(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`
}
+/** Copy host Anthropic auth into the sandbox process. Docker `exec` Env replaces the container env, so a key set only at create time can vanish. */
+function hostClaudeAuthEnv(): Record {
+ const env: Record = {}
+ const apiKey = process.env.ANTHROPIC_API_KEY
+ if (apiKey) env.ANTHROPIC_API_KEY = apiKey
+ const authToken = process.env.ANTHROPIC_AUTH_TOKEN
+ if (authToken) env.ANTHROPIC_AUTH_TOKEN = authToken
+ return env
+}
+
+/**
+ * Windows Node often has USERPROFILE but no HOME. Claude then cannot find
+ * `~/.claude.json` (the `claude login` file) and prints "Not logged in".
+ */
+function localProcessHomeEnv(provider: string): Record {
+ if (provider !== 'local-process') return {}
+ if (process.env.HOME) return {}
+ const home = process.env.USERPROFILE
+ return home ? { HOME: home } : {}
+}
+
/** Format a host tool-bridge as claude's `--mcp-config` JSON. */
function bridgeToMcpConfig(bridge: HostToolBridge): string {
return JSON.stringify({
@@ -153,56 +188,71 @@ export class ClaudeCodeTextAdapter<
)
}
+ supportsCombinedToolsAndSchema(): boolean {
+ return true
+ }
+
+ combinedStructuredOutputSource(): 'event' {
+ return 'event'
+ }
+
/** Build the `claude` command line (prompt goes via stdin, not argv). */
- private buildCommand(
+ private buildArgv(
options: TextOptions,
resume: string | undefined,
policyFlags: ClaudePolicyFlags,
mcpConfigPath: string | undefined,
permissionPromptTool: string | undefined,
- ): string {
+ hasJsonSchema: boolean,
+ ): Array {
const config = this.adapterConfig
const modelOptions = options.modelOptions
- const exe = config.claudeExecutable ?? 'claude'
+ const exeParts = (config.claudeExecutable ?? 'claude').split(' ')
+ // `--setting-sources user` before `-p`. Do not pass `--bare`: that flag
+ // skips stored `claude login` credentials and prints
+ // "Not logged in · Please run /login" (claude-code#51047).
+ // `-p` can take the next token as the prompt, so these flags stay first.
const args: Array = [
+ ...exeParts,
+ '--setting-sources',
+ 'user',
'-p',
'--output-format',
'stream-json',
'--verbose',
'--model',
- q(this.model),
+ this.model,
]
if (config.streamPartials !== false) args.push('--include-partial-messages')
- if (resume !== undefined) args.push('--resume', q(resume))
+ if (resume !== undefined) args.push('--resume', resume)
- // Precedence: per-call modelOptions > adapter config > policy > sandbox default.
const permissionMode =
modelOptions?.permissionMode ??
config.permissionMode ??
policyFlags.permissionMode ??
'bypassPermissions'
- args.push('--permission-mode', q(permissionMode))
+ args.push('--permission-mode', permissionMode)
const maxTurns = modelOptions?.maxTurns ?? config.maxTurns
if (maxTurns !== undefined) args.push('--max-turns', String(maxTurns))
- for (const dir of config.addDirs ?? []) args.push('--add-dir', q(dir))
+ for (const dir of config.addDirs ?? []) args.push('--add-dir', dir)
const allowedTools = [
...(modelOptions?.allowedTools ?? config.allowedTools ?? []),
...policyFlags.allowedTools,
]
if (allowedTools.length > 0) {
- args.push('--allowedTools', q([...new Set(allowedTools)].join(',')))
+ args.push('--allowedTools', [...new Set(allowedTools)].join(','))
}
const disallowedTools = [
...(modelOptions?.disallowedTools ?? config.disallowedTools ?? []),
...policyFlags.disallowedTools,
]
if (disallowedTools.length > 0) {
- args.push('--disallowedTools', q([...new Set(disallowedTools)].join(',')))
+ args.push('--disallowedTools', [...new Set(disallowedTools)].join(','))
}
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
@@ -214,15 +264,18 @@ export class ClaudeCodeTextAdapter<
config.systemPromptMode === 'replace'
? '--system-prompt'
: '--append-system-prompt'
- args.push(flag, q(joined))
+ args.push(flag, joined)
}
- if (mcpConfigPath !== undefined) args.push('--mcp-config', q(mcpConfigPath))
+ if (mcpConfigPath !== undefined) args.push('--mcp-config', mcpConfigPath)
+ if (hasJsonSchema) {
+ args.push('--json-schema', CLAUDE_JSON_SCHEMA_PLACEHOLDER)
+ }
if (permissionPromptTool !== undefined) {
- args.push('--permission-prompt-tool', q(permissionPromptTool))
+ args.push('--permission-prompt-tool', permissionPromptTool)
}
- return `${exe} ${args.join(' ')}`
+ return args
}
/**
@@ -308,6 +361,7 @@ export class ClaudeCodeTextAdapter<
const sandbox = this.sandboxFrom(options)
cleanupSandbox = sandbox
const cwd = this.workdir(options)
+
// Durability comes from `withSandbox(sandbox, { runs, durability })`, read
// back off the capability bus. Absent it, everything below resolves to
// exactly today's behavior (no journal option, no alignment, and a
@@ -390,10 +444,15 @@ export class ClaudeCodeTextAdapter<
})
}
- const { prompt, resume } = buildPrompt(
+ const built = buildPrompt(
options.messages,
options.modelOptions?.sessionId,
)
+ const resume = built.resume
+ const prompt =
+ options.outputSchema !== undefined
+ ? appendOutputSchemaInstruction(built.prompt, options.outputSchema)
+ : built.prompt
// Both files below name themselves after `runId`, and durability makes
// `runId` CALLER-chosen. Raw, a `/` in it would silently turn each basename
// into a nested path (writing outside the intended directory, or failing on
@@ -421,7 +480,17 @@ export class ClaudeCodeTextAdapter<
tempFiles.push(mcpConfigPath)
mcpConfigArg = mcpConfigFile
}
- const command = this.buildCommand(
+ let jsonSchemaFile: string | undefined
+ if (options.outputSchema !== undefined) {
+ jsonSchemaFile = `tanstack-output-schema-${runIdSegment}.json`
+ const schemaPath = `${cwd}/${jsonSchemaFile}`
+ await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema))
+ tempFiles.push(schemaPath)
+ }
+ const runnerFile = `tanstack-claude-run-${runIdSegment}.mjs`
+ await sandbox.fs.write(`${cwd}/${runnerFile}`, CLAUDE_RUNNER_SOURCE)
+ tempFiles.push(`${cwd}/${runnerFile}`)
+ const argv = this.buildArgv(
options,
resume,
mapPolicyToClaudeFlags(policy),
@@ -429,7 +498,17 @@ export class ClaudeCodeTextAdapter<
bridge && permission
? `mcp__${bridge.name}__${permission.toolName}`
: undefined,
+ jsonSchemaFile !== undefined,
)
+ // Filenames only on the shell line. JSON (schema, system prompt, MCP
+ // config path) lives in the argv file so git-bash cannot retokenize it.
+ const argvFile = `tanstack-claude-argv-${runIdSegment}.json`
+ await sandbox.fs.write(`${cwd}/${argvFile}`, JSON.stringify(argv))
+ tempFiles.push(`${cwd}/${argvFile}`)
+ const command =
+ jsonSchemaFile === undefined
+ ? `node ${q(runnerFile)} ${q(argvFile)}`
+ : `node ${q(runnerFile)} ${q(argvFile)} ${q(jsonSchemaFile)}`
// Deliver the prompt. The default feeds it over stdin (keeps it out of
// argv). Providers without a writable host→process stdin (e.g. Cloudflare)
@@ -450,17 +529,31 @@ export class ClaudeCodeTextAdapter<
{ provider: 'claude-code', model: this.model },
)
+ const authMode =
+ options.modelOptions?.authMode ??
+ this.adapterConfig.authMode ??
+ 'api-key'
+ const injectApiKey = authMode === 'api-key'
+
const journalOptions = journalOptionsFor(durability, runId)
const rawEvents = spawnNdjson(sandbox, runCommand, {
cwd,
...(stdinInput !== undefined ? { input: stdinInput } : {}),
- // claude maps `bypassPermissions` to `--dangerously-skip-permissions`,
- // which it refuses to run as root. Sandbox containers routinely run as
- // root (Docker / Cloudflare), so set `IS_SANDBOX=1` — claude's
- // documented escape hatch for skip-permissions in an isolated
- // environment — merged over the sandbox env (a caller-provided value
- // wins). Safe to set unconditionally; it is a no-op for stricter modes.
- env: { IS_SANDBOX: '1', ...this.adapterConfig.env },
+ // Isolated sandboxes often run as root. Claude refuses
+ // `--dangerously-skip-permissions` as root unless IS_SANDBOX=1.
+ // CLAUDE_CODE_SANDBOXED marks a real isolation boundary. Do not set
+ // either on local-process: that provider runs on the host.
+ env: {
+ ...(sandbox.provider === 'local-process'
+ ? {}
+ : {
+ IS_SANDBOX: '1',
+ CLAUDE_CODE_SANDBOXED: '1',
+ }),
+ ...(injectApiKey ? hostClaudeAuthEnv() : {}),
+ ...localProcessHomeEnv(sandbox.provider),
+ ...this.adapterConfig.env,
+ },
...(options.abortController?.signal
? { signal: options.abortController.signal }
: options.request?.signal
@@ -509,6 +602,7 @@ export class ClaudeCodeTextAdapter<
// which mixes in `Date.now()` / `Math.random()`). See
// `createRunScopedIdGen` in `@tanstack/ai-sandbox`.
genId: createRunScopedIdGen(runId),
+ ...(options.outputSchema ? { expectStructuredOutput: true } : {}),
onSdkMessage: (message) =>
logger.provider(`provider=claude-code type=${message.type}`, {
chunk: message,
@@ -585,8 +679,8 @@ export class ClaudeCodeTextAdapter<
): Promise> {
return Promise.reject(
new Error(
- 'Structured output is not yet supported by the in-sandbox Claude Code adapter. ' +
- 'Use a model adapter (e.g. anthropic) for structured output, or omit outputSchema.',
+ 'This harness honors outputSchema on chat() in the same turn. ' +
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
),
)
}
diff --git a/packages/ai-claude-code/src/provider-options.ts b/packages/ai-claude-code/src/provider-options.ts
index 4a40803b27..7e1d2612e3 100644
--- a/packages/ai-claude-code/src/provider-options.ts
+++ b/packages/ai-claude-code/src/provider-options.ts
@@ -27,4 +27,10 @@ export interface ClaudeCodeTextProviderOptions {
disallowedTools?: Array
/** Per-call override of the harness working directory. */
cwd?: string
+ /**
+ * `'api-key'` (default) injects `ANTHROPIC_API_KEY`.
+ * `'host'` uses `claude login`.
+ * Not inferred from the sandbox.
+ */
+ authMode?: 'host' | 'api-key'
}
diff --git a/packages/ai-claude-code/src/stream/sdk-types.ts b/packages/ai-claude-code/src/stream/sdk-types.ts
index a4b40be743..ab1ffcfc5c 100644
--- a/packages/ai-claude-code/src/stream/sdk-types.ts
+++ b/packages/ai-claude-code/src/stream/sdk-types.ts
@@ -60,12 +60,22 @@ export type SdkRawStreamEvent =
| {
type: 'content_block_start'
index: number
- content_block: { type: string }
+ content_block: {
+ type: string
+ id?: string
+ name?: string
+ input?: unknown
+ }
}
| {
type: 'content_block_delta'
index: number
- delta: { type: string; text?: string; thinking?: string }
+ delta: {
+ type: string
+ text?: string
+ thinking?: string
+ partial_json?: string
+ }
}
| { type: 'content_block_stop'; index: number }
| { type: 'message_delta' }
diff --git a/packages/ai-claude-code/src/stream/translate.ts b/packages/ai-claude-code/src/stream/translate.ts
index 67271d63c7..072cc90a82 100644
--- a/packages/ai-claude-code/src/stream/translate.ts
+++ b/packages/ai-claude-code/src/stream/translate.ts
@@ -1,4 +1,9 @@
import { EventType, buildBaseUsage } from '@tanstack/ai'
+import {
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+} from '@tanstack/ai/adapter-internals'
import type { StreamChunk, TokenUsage } from '@tanstack/ai'
import type {
AgentSdkMessage,
@@ -18,6 +23,13 @@ export const BRIDGED_MCP_SERVER_NAME = 'tanstack'
const BRIDGED_MCP_PREFIX = `mcp__${BRIDGED_MCP_SERVER_NAME}__`
+/**
+ * Claude Code `--json-schema` injects a fake tool named StructuredOutput.
+ * The model "calls" it with the schema JSON. The result message may omit
+ * `structured_output`; harvest the tool input in that case.
+ */
+export const SYNTHETIC_STRUCTURED_OUTPUT_TOOL = 'StructuredOutput'
+
/** Claude Code-specific usage details attached to RUN_FINISHED usage. */
export type ClaudeCodeProviderUsageDetails = {
/** Total cost of the harness run in USD, as reported by Claude Code. */
@@ -34,6 +46,8 @@ export interface TranslateContext {
onSessionId?: (sessionId: string) => void
/** Called for each raw SDK message, for logging. */
onSdkMessage?: (message: AgentSdkMessage) => void
+ /** Emit structured-output events from `result.structured_output`. */
+ expectStructuredOutput?: boolean
}
/**
@@ -47,6 +61,19 @@ export function stripMcpPrefix(name: string): string {
: name
}
+function isUsefulStructuredObject(value: unknown): boolean {
+ return (
+ value !== null &&
+ typeof value === 'object' &&
+ !Array.isArray(value) &&
+ Object.keys(value).length > 0
+ )
+}
+
+function rememberStructuredOutput(current: unknown, next: unknown): unknown {
+ return isUsefulStructuredObject(next) ? next : current
+}
+
function stringifyToolResultContent(
content: SdkToolResultContent | undefined,
): string {
@@ -109,6 +136,11 @@ export async function* translateSdkStream(
let runStarted = false
/** Tool calls started but with no result yet. */
const unresolvedToolCalls = new Set()
+ const syntheticOutputToolIds = new Set()
+ let capturedStructuredOutput: unknown
+ let assistantTextForHarvest = ''
+ let partialStructuredJson = ''
+ let partialIsStructuredOutput = false
/** Anthropic message ids whose text/thinking already streamed via partials. */
const streamedMessageIds = new Set()
@@ -179,12 +211,42 @@ export async function* translateSdkStream(
partialReasoningId = null
}
+ function* emitStructuredOutput(object: unknown): Generator {
+ const raw = JSON.stringify(object)
+ const messageId = genId()
+ yield structuredOutputStartChunk({
+ messageId,
+ model,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId,
+ model,
+ threadId,
+ runId,
+ object,
+ raw,
+ })
+ }
+
function* emitToolUse(block: {
id: string
name: string
input: unknown
}): Generator {
const toolCallName = stripMcpPrefix(block.name)
+ if (
+ ctx.expectStructuredOutput === true &&
+ toolCallName === SYNTHETIC_STRUCTURED_OUTPUT_TOOL
+ ) {
+ capturedStructuredOutput = rememberStructuredOutput(
+ capturedStructuredOutput,
+ block.input,
+ )
+ syntheticOutputToolIds.add(block.id)
+ return
+ }
const args = JSON.stringify(block.input ?? {})
yield {
type: EventType.TOOL_CALL_START,
@@ -226,6 +288,7 @@ export async function* translateSdkStream(
if (alreadyStreamed) continue
const messageId = message.message.id ?? genId()
const text = (block as { text: string }).text
+ if (!alreadyStreamed) assistantTextForHarvest += text
yield {
type: EventType.TEXT_MESSAGE_START,
messageId,
@@ -301,6 +364,10 @@ export async function* translateSdkStream(
content?: SdkToolResultContent
is_error?: boolean
}
+ if (syntheticOutputToolIds.has(toolResult.tool_use_id)) {
+ syntheticOutputToolIds.delete(toolResult.tool_use_id)
+ continue
+ }
unresolvedToolCalls.delete(toolResult.tool_use_id)
yield {
type: EventType.TOOL_CALL_RESULT,
@@ -320,6 +387,29 @@ export async function* translateSdkStream(
yield* synthesizeUnresolvedResults()
const usage = buildUsage(message.usage, message.total_cost_usd)
+ if (ctx.expectStructuredOutput === true) {
+ const fromResult = isUsefulStructuredObject(message.structured_output)
+ ? message.structured_output
+ : undefined
+ const fromTool = isUsefulStructuredObject(capturedStructuredOutput)
+ ? capturedStructuredOutput
+ : undefined
+ let fromText: unknown
+ if (fromResult === undefined && fromTool === undefined) {
+ const raw = assistantTextForHarvest || message.result || ''
+ if (raw.trim() !== '') {
+ try {
+ fromText = parseJsonFromAssistantText(raw)
+ } catch {
+ fromText = undefined
+ }
+ }
+ }
+ const object = fromResult ?? fromTool ?? fromText
+ if (isUsefulStructuredObject(object)) {
+ yield* emitStructuredOutput(object)
+ }
+ }
if (message.subtype === 'success') {
yield {
type: EventType.RUN_FINISHED,
@@ -365,6 +455,24 @@ export async function* translateSdkStream(
streamedMessageIds.add(partialMessageId)
} else if (event.type === 'content_block_start') {
partialBlockType = event.content_block.type
+ const startedBlock = event.content_block
+ partialIsStructuredOutput =
+ ctx.expectStructuredOutput === true &&
+ startedBlock.type === 'tool_use' &&
+ 'name' in startedBlock &&
+ startedBlock.name === SYNTHETIC_STRUCTURED_OUTPUT_TOOL
+ if (partialIsStructuredOutput) {
+ partialStructuredJson = ''
+ if ('id' in startedBlock && typeof startedBlock.id === 'string') {
+ syntheticOutputToolIds.add(startedBlock.id)
+ }
+ if ('input' in startedBlock) {
+ capturedStructuredOutput = rememberStructuredOutput(
+ capturedStructuredOutput,
+ startedBlock.input,
+ )
+ }
+ }
if (partialBlockType === 'text') {
partialTextMessageId = partialMessageId ?? genId()
partialTextContent = ''
@@ -402,6 +510,7 @@ export async function* translateSdkStream(
typeof event.delta.text === 'string'
) {
partialTextContent += event.delta.text
+ assistantTextForHarvest += event.delta.text
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: partialTextMessageId,
@@ -410,6 +519,12 @@ export async function* translateSdkStream(
delta: event.delta.text,
content: partialTextContent,
}
+ } else if (
+ event.delta.type === 'input_json_delta' &&
+ partialIsStructuredOutput &&
+ typeof event.delta.partial_json === 'string'
+ ) {
+ partialStructuredJson += event.delta.partial_json
} else if (
event.delta.type === 'thinking_delta' &&
partialReasoningId &&
@@ -424,12 +539,24 @@ export async function* translateSdkStream(
}
}
} else if (event.type === 'content_block_stop') {
+ if (partialIsStructuredOutput && partialStructuredJson !== '') {
+ try {
+ capturedStructuredOutput = rememberStructuredOutput(
+ capturedStructuredOutput,
+ JSON.parse(partialStructuredJson),
+ )
+ } catch {
+ // Incomplete JSON; the complete assistant tool_use may still arrive.
+ }
+ }
if (partialBlockType === 'text') {
yield* closePartialText()
} else if (partialBlockType === 'thinking') {
yield* closePartialReasoning()
}
partialBlockType = null
+ partialIsStructuredOutput = false
+ partialStructuredJson = ''
}
}
diff --git a/packages/ai-claude-code/tests/attach.test.ts b/packages/ai-claude-code/tests/attach.test.ts
index ba6d0ff4e1..784430f798 100644
--- a/packages/ai-claude-code/tests/attach.test.ts
+++ b/packages/ai-claude-code/tests/attach.test.ts
@@ -50,8 +50,8 @@ afterAll(async () => {
// Same stand-in as `text-adapter.test.ts`: ignores its flags, reads the
// prompt from stdin, then emits stream-json (system/init → assistant text →
-// result). Its filename ("fake-claude") is asserted to be ABSENT from the
-// spawned commands on the attach path, proving the agent was never started.
+// result). The runner filename is asserted to be ABSENT from the spawned
+// commands on the attach path, proving the agent was never started.
const FAKE_CLAUDE = [
`let input = ''`,
`process.stdin.on('data', (d) => { input += d })`,
@@ -152,10 +152,12 @@ describe('claude-code durable-run wiring (attach path)', () => {
)
expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
- // The agent still ran directly (unjournaled): its own command shows up,
+ // The agent still ran directly (unjournaled): the argv runner shows up,
// but no command anywhere references a journal path for this runId.
expect(
- recorder.spawned.some((command) => command.includes('fake-claude.mjs')),
+ recorder.spawned.some((command) =>
+ command.includes('tanstack-claude-run-'),
+ ),
).toBe(true)
const paths = journalPaths(runId)
expect(
@@ -192,9 +194,11 @@ describe('claude-code durable-run wiring (attach path)', () => {
expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
- // The agent was actually started (`startJournaledAgent` spawns it).
+ // The agent was actually started (`startJournaledAgent` spawns the runner).
expect(
- recorder.spawned.some((command) => command.includes('fake-claude.mjs')),
+ recorder.spawned.some((command) =>
+ command.includes('tanstack-claude-run-'),
+ ),
).toBe(true)
// ...and its output was journaled under a path derived from THIS runId.
@@ -273,10 +277,12 @@ describe('claude-code durable-run wiring (attach path)', () => {
expect(text).toContain('resumed')
expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
- // No command ever referenced the agent executable: the agent was never
+ // No command ever referenced the argv runner: the agent was never
// started on the attach path.
expect(
- recorder.spawned.some((command) => command.includes('fake-claude.mjs')),
+ recorder.spawned.some((command) =>
+ command.includes('tanstack-claude-run-'),
+ ),
).toBe(false)
// A read command against this run's journal DID happen.
diff --git a/packages/ai-claude-code/tests/text-adapter.test.ts b/packages/ai-claude-code/tests/text-adapter.test.ts
index fe468ccc43..a7b300bdec 100644
--- a/packages/ai-claude-code/tests/text-adapter.test.ts
+++ b/packages/ai-claude-code/tests/text-adapter.test.ts
@@ -34,8 +34,7 @@ const FAKE_CLAUDE = [
`process.stdin.on('end', () => {`,
` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`,
` w({ type: 'system', subtype: 'init', session_id: 'sess-abc', model: 'haiku', tools: [] })`,
- // Echo IS_SANDBOX so the test can assert the adapter sets it (claude refuses
- // bypassPermissions as root without it).
+ // Echo IS_SANDBOX so the test can assert local-process does not set it.
` w({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'pong IS_SANDBOX=' + process.env.IS_SANDBOX }] }, parent_tool_use_id: null })`,
` w({ type: 'result', subtype: 'success', result: 'pong', usage: { input_tokens: 1, output_tokens: 1 } })`,
`})`,
@@ -107,9 +106,8 @@ describe('claude-code in-sandbox adapter', () => {
.map((c) => (c as { delta?: string }).delta ?? '')
.join('')
expect(text).toContain('pong')
- // The adapter must set IS_SANDBOX=1 in the CLI env (claude refuses
- // `--dangerously-skip-permissions`/bypassPermissions as root otherwise).
- expect(text).toContain('IS_SANDBOX=1')
+ // Isolated sandboxes set IS_SANDBOX=1. local-process must not.
+ expect(text).toContain('IS_SANDBOX=undefined')
expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
@@ -161,4 +159,105 @@ describe('claude-code in-sandbox adapter', () => {
expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
await sbx.destroy()
})
+
+ it('passes --json-schema and emits structured-output.complete', async () => {
+ const fake = [
+ `import { writeFileSync } from 'node:fs'`,
+ `writeFileSync('argv.txt', process.argv.slice(2).join(' '))`,
+ `let input = ''`,
+ `process.stdin.on('data', (d) => { input += d })`,
+ `process.stdin.on('end', () => {`,
+ ` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`,
+ ` w({ type: 'system', subtype: 'init', session_id: 'sess-so', model: 'haiku', tools: [] })`,
+ ` w({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'looking' }] }, parent_tool_use_id: null })`,
+ ` w({ type: 'result', subtype: 'success', result: 'done', structured_output: { summary: 'ok' }, usage: { input_tokens: 1, output_tokens: 1 } })`,
+ `})`,
+ ].join('\n')
+
+ const sbx = await provider.create({})
+ await sbx.fs.write('/workspace/fake-claude.mjs', fake)
+
+ const adapter = claudeCodeText('haiku', {
+ claudeExecutable: 'node fake-claude.mjs',
+ streamPartials: false,
+ emitDiff: false,
+ })
+
+ const chunks = await collect(
+ adapter.chatStream({
+ model: 'haiku',
+ messages: [{ role: 'user', content: 'summarize' }],
+ logger: noopLogger,
+ capabilities: capabilityContextWith(sbx),
+ outputSchema: {
+ type: 'object',
+ properties: { summary: { type: 'string' } },
+ required: ['summary'],
+ },
+ }),
+ )
+
+ const argv = await sbx.fs.read('/workspace/argv.txt')
+ expect(argv).not.toContain('--bare')
+ expect(argv).toContain('--setting-sources')
+ expect(argv).toContain('user')
+ expect(argv).toContain('--json-schema')
+ expect(argv).toContain('"type":"object"')
+ expect(argv).toContain('"summary"')
+ expect(argv).not.toContain('tanstack-output-schema')
+ expect(argv).not.toMatch(/--json-schema\s+\./)
+
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({ object: { summary: 'ok' } }),
+ )
+ }
+
+ await sbx.destroy()
+ })
+
+ it('copies ANTHROPIC_API_KEY from the host process into the CLI env', async () => {
+ const fake = [
+ `import { writeFileSync } from 'node:fs'`,
+ `writeFileSync('auth-probe.txt', process.env.ANTHROPIC_API_KEY ? 'set' : 'missing')`,
+ `let input = ''`,
+ `process.stdin.on('data', (d) => { input += d })`,
+ `process.stdin.on('end', () => {`,
+ ` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`,
+ ` w({ type: 'system', subtype: 'init', session_id: 'sess-auth', model: 'haiku', tools: [] })`,
+ ` w({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'ok' }] }, parent_tool_use_id: null })`,
+ ` w({ type: 'result', subtype: 'success', result: 'ok', usage: { input_tokens: 1, output_tokens: 1 } })`,
+ `})`,
+ ].join('\n')
+
+ const previous = process.env.ANTHROPIC_API_KEY
+ process.env.ANTHROPIC_API_KEY = 'sk-test-not-a-real-key'
+ const sbx = await provider.create({})
+ try {
+ await sbx.fs.write('/workspace/fake-claude.mjs', fake)
+ const adapter = claudeCodeText('haiku', {
+ claudeExecutable: 'node fake-claude.mjs',
+ streamPartials: false,
+ emitDiff: false,
+ })
+ const chunks = await collect(
+ adapter.chatStream({
+ model: 'haiku',
+ messages: [{ role: 'user', content: 'hi' }],
+ logger: noopLogger,
+ capabilities: capabilityContextWith(sbx),
+ }),
+ )
+ expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(false)
+ expect(await sbx.fs.read('/workspace/auth-probe.txt')).toBe('set')
+ } finally {
+ if (previous === undefined) delete process.env.ANTHROPIC_API_KEY
+ else process.env.ANTHROPIC_API_KEY = previous
+ await sbx.destroy()
+ }
+ })
})
diff --git a/packages/ai-claude-code/tests/translate.test.ts b/packages/ai-claude-code/tests/translate.test.ts
index 607d2c4573..5c6fe1a761 100644
--- a/packages/ai-claude-code/tests/translate.test.ts
+++ b/packages/ai-claude-code/tests/translate.test.ts
@@ -23,12 +23,12 @@ async function* fromArray(
async function collect(
messages: Array,
+ context: ReturnType & {
+ expectStructuredOutput?: boolean
+ } = makeContext(),
): Promise> {
const chunks: Array = []
- for await (const chunk of translateSdkStream(
- fromArray(messages),
- makeContext(),
- )) {
+ for await (const chunk of translateSdkStream(fromArray(messages), context)) {
chunks.push(chunk)
}
return chunks
@@ -482,4 +482,251 @@ describe('translateSdkStream', () => {
'RUN_FINISHED',
])
})
+
+ it('emits structured-output events from result.structured_output when expected', async () => {
+ const resultWithObject: AgentSdkMessage = {
+ type: 'result',
+ subtype: 'success',
+ result: 'done',
+ usage,
+ structured_output: { summary: 'ok' },
+ }
+ const chunks = await collect(
+ [init, assistantText('Looking around.'), resultWithObject],
+ { ...makeContext(), expectStructuredOutput: true },
+ )
+ const start = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.start',
+ )
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(start).toBeDefined()
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({
+ object: { summary: 'ok' },
+ raw: JSON.stringify({ summary: 'ok' }),
+ }),
+ )
+ }
+ expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(true)
+ expect(chunks.some((c) => c.type === 'RUN_FINISHED')).toBe(true)
+ })
+
+ it('harvests StructuredOutput tool input when result.structured_output is missing', async () => {
+ const report = {
+ name: 'TanStack AI',
+ oneLiner: 'Type-safe AI SDK',
+ }
+ const chunks = await collect(
+ [
+ init,
+ assistantText('I have enough to compile the report.'),
+ {
+ type: 'assistant',
+ message: {
+ id: 'msg-so',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'toolu_so',
+ name: 'StructuredOutput',
+ input: report,
+ },
+ ],
+ },
+ parent_tool_use_id: null,
+ },
+ {
+ type: 'user',
+ message: {
+ role: 'user',
+ content: [
+ {
+ type: 'tool_result',
+ tool_use_id: 'toolu_so',
+ content: 'ok',
+ },
+ ],
+ },
+ parent_tool_use_id: null,
+ },
+ resultSuccess,
+ ],
+ { ...makeContext(), expectStructuredOutput: true },
+ )
+
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({
+ object: report,
+ raw: JSON.stringify(report),
+ }),
+ )
+ }
+ expect(
+ chunks.some(
+ (c) =>
+ c.type === 'TOOL_CALL_START' &&
+ 'toolCallName' in c &&
+ c.toolCallName === 'StructuredOutput',
+ ),
+ ).toBe(false)
+ expect(chunks.some((c) => c.type === 'TOOL_CALL_RESULT')).toBe(false)
+ })
+
+ it('does not let an empty StructuredOutput tool wipe a captured object', async () => {
+ const report = { name: 'TanStack AI', oneLiner: 'SDK' }
+ const chunks = await collect(
+ [
+ init,
+ {
+ type: 'assistant',
+ message: {
+ id: 'msg-so-1',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'toolu_so_full',
+ name: 'StructuredOutput',
+ input: report,
+ },
+ ],
+ },
+ parent_tool_use_id: null,
+ },
+ {
+ type: 'assistant',
+ message: {
+ id: 'msg-so-2',
+ content: [
+ {
+ type: 'tool_use',
+ id: 'toolu_so_empty',
+ name: 'StructuredOutput',
+ input: {},
+ },
+ ],
+ },
+ parent_tool_use_id: null,
+ },
+ resultSuccess,
+ ],
+ { ...makeContext(), expectStructuredOutput: true },
+ )
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete?.type === 'CUSTOM' && complete.value).toEqual(
+ expect.objectContaining({ object: report }),
+ )
+ })
+
+ it('harvests StructuredOutput from streamed input_json_delta', async () => {
+ const report = { name: 'TanStack AI', oneLiner: 'SDK' }
+ const chunks = await collect(
+ [
+ init,
+ {
+ type: 'stream_event',
+ event: { type: 'message_start', message: { id: 'msg-p' } },
+ parent_tool_use_id: null,
+ },
+ {
+ type: 'stream_event',
+ event: {
+ type: 'content_block_start',
+ index: 0,
+ content_block: {
+ type: 'tool_use',
+ id: 'toolu_stream',
+ name: 'StructuredOutput',
+ input: {},
+ },
+ },
+ parent_tool_use_id: null,
+ },
+ {
+ type: 'stream_event',
+ event: {
+ type: 'content_block_delta',
+ index: 0,
+ delta: {
+ type: 'input_json_delta',
+ partial_json: JSON.stringify(report),
+ },
+ },
+ parent_tool_use_id: null,
+ },
+ {
+ type: 'stream_event',
+ event: { type: 'content_block_stop', index: 0 },
+ parent_tool_use_id: null,
+ },
+ resultSuccess,
+ ],
+ { ...makeContext(), expectStructuredOutput: true },
+ )
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete?.type === 'CUSTOM' && complete.value).toEqual(
+ expect.objectContaining({ object: report }),
+ )
+ })
+
+ it('parses JSON from assistant text when the StructuredOutput tool is missing', async () => {
+ const report = { name: 'TanStack AI', oneLiner: 'SDK' }
+ const chunks = await collect(
+ [init, assistantText(JSON.stringify(report)), resultSuccess],
+ { ...makeContext(), expectStructuredOutput: true },
+ )
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete?.type === 'CUSTOM' && complete.value).toEqual(
+ expect.objectContaining({ object: report }),
+ )
+ })
+
+ it('does not emit structured-output events when the flag is off', async () => {
+ const resultWithObject: AgentSdkMessage = {
+ type: 'result',
+ subtype: 'success',
+ result: 'done',
+ usage,
+ structured_output: { summary: 'ok' },
+ }
+ const chunks = await collect([
+ init,
+ assistantText('Looking around.'),
+ resultWithObject,
+ ])
+ expect(
+ chunks.some(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ ),
+ ).toBe(false)
+ })
+
+ it('maps error_max_structured_output_retries to RUN_ERROR', async () => {
+ const failed: AgentSdkMessage = {
+ type: 'result',
+ subtype: 'error_max_structured_output_retries',
+ errors: ['schema retries exhausted'],
+ usage,
+ }
+ const chunks = await collect([init, failed])
+ const err = chunks.find((c) => c.type === 'RUN_ERROR')
+ expect(err).toBeDefined()
+ if (err?.type === 'RUN_ERROR') {
+ expect(err.message).toContain('schema retries exhausted')
+ }
+ })
})
diff --git a/packages/ai-codex/src/adapters/text.ts b/packages/ai-codex/src/adapters/text.ts
index 85583df937..9025110990 100644
--- a/packages/ai-codex/src/adapters/text.ts
+++ b/packages/ai-codex/src/adapters/text.ts
@@ -64,6 +64,8 @@ function defaultSandboxMode(provider: string): CodexSandboxMode {
: 'workspace-write'
}
+export type CodexAuthMode = 'host' | 'api-key'
+
export interface CodexTextConfig {
/** Working directory inside the sandbox. Defaults to `/workspace`. */
cwd?: string
@@ -87,6 +89,11 @@ export interface CodexTextConfig {
additionalDirectories?: Array
/** Path/name of the codex executable inside the sandbox. Defaults to `codex`. */
codexExecutable?: string
+ /**
+ * `'api-key'` (default) expects `CODEX_API_KEY` in the process or sandbox
+ * secrets. `'host'` uses `codex login`. Not inferred from the sandbox.
+ */
+ authMode?: CodexAuthMode
/** Extra environment variables for the codex process inside the sandbox. */
env?: Record
/** Extra raw `--config key=value` overrides (values passed verbatim as TOML). */
@@ -139,6 +146,14 @@ export class CodexTextAdapter<
)
}
+ supportsCombinedToolsAndSchema(): boolean {
+ return true
+ }
+
+ combinedStructuredOutputSource(): 'event' {
+ return 'event'
+ }
+
/** Mirror @openai/codex-sdk's `codex exec --experimental-json` invocation. */
private buildCommand(
options: TextOptions,
@@ -146,6 +161,7 @@ export class CodexTextAdapter<
bridge: HostToolBridge | undefined,
policyFlags: CodexPolicyFlags,
provider: string,
+ outputSchemaPath: string | undefined,
): string {
const config = this.adapterConfig
const modelOptions = options.modelOptions
@@ -214,6 +230,10 @@ export class CodexTextAdapter<
args.push('--config', q(`${key}=${value}`))
}
+ if (outputSchemaPath !== undefined) {
+ args.push('--output-schema', q(outputSchemaPath))
+ }
+
// Resume an existing thread (mirrors the SDK's `resume `).
if (resume !== undefined) args.push('resume', q(resume))
@@ -304,12 +324,21 @@ export class CodexTextAdapter<
const policy = options.capabilities
? getSandboxPolicy(options.capabilities, { optional: true })
: undefined
+ let outputSchemaArg: string | undefined
+ if (options.outputSchema) {
+ const schemaFile = `.tanstack-output-schema-${encodeRunId(runId)}.json`
+ const schemaPath = `${cwd}/${schemaFile}`
+ await sandbox.fs.write(schemaPath, JSON.stringify(options.outputSchema))
+ tempFiles.push(schemaPath)
+ outputSchemaArg = schemaFile
+ }
const command = this.buildCommand(
options,
resume,
bridge,
mapPolicyToCodexFlags(policy),
sandbox.provider,
+ outputSchemaArg,
)
logger.request(
@@ -409,6 +438,7 @@ export class CodexTextAdapter<
parentRunId: options.parentRunId,
}),
genId,
+ ...(options.outputSchema ? { expectStructuredOutput: true } : {}),
onThreadEvent: (event) =>
logger.provider(`provider=codex type=${event.type}`, {
chunk: event,
@@ -458,8 +488,8 @@ export class CodexTextAdapter<
): Promise> {
return Promise.reject(
new Error(
- 'Structured output is not yet supported by the in-sandbox Codex adapter. ' +
- 'Use a model adapter for structured output, or omit outputSchema.',
+ 'This harness honors outputSchema on chat() in the same turn. ' +
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
),
)
}
diff --git a/packages/ai-codex/src/index.ts b/packages/ai-codex/src/index.ts
index 4d127524cc..7dd54b9ed3 100644
--- a/packages/ai-codex/src/index.ts
+++ b/packages/ai-codex/src/index.ts
@@ -1,6 +1,7 @@
export { CodexTextAdapter, codexText } from './adapters/text'
export type {
CodexTextConfig,
+ CodexAuthMode,
CodexSandboxMode,
CodexApprovalMode,
} from './adapters/text'
diff --git a/packages/ai-codex/src/provider-options.ts b/packages/ai-codex/src/provider-options.ts
index 5f34a3b10d..54d82bfb2f 100644
--- a/packages/ai-codex/src/provider-options.ts
+++ b/packages/ai-codex/src/provider-options.ts
@@ -20,4 +20,10 @@ export interface CodexTextProviderOptions {
workingDirectory?: string
/** Per-call override of the git-repo safety check (defaults to skipping). */
skipGitRepoCheck?: boolean
+ /**
+ * `'api-key'` (default) expects `CODEX_API_KEY`.
+ * `'host'` uses `codex login`.
+ * Not inferred from the sandbox.
+ */
+ authMode?: 'host' | 'api-key'
}
diff --git a/packages/ai-codex/src/stream/translate.ts b/packages/ai-codex/src/stream/translate.ts
index 082e26ba3e..ff855ef649 100644
--- a/packages/ai-codex/src/stream/translate.ts
+++ b/packages/ai-codex/src/stream/translate.ts
@@ -1,4 +1,9 @@
import { EventType, buildBaseUsage } from '@tanstack/ai'
+import {
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+} from '@tanstack/ai/adapter-internals'
import type { StreamChunk, TokenUsage } from '@tanstack/ai'
import type { CodexThreadEvent, CodexThreadItem, CodexUsage } from './sdk-types'
@@ -18,6 +23,8 @@ export interface TranslateContext {
onSessionId?: (sessionId: string) => void
/** Called for each raw SDK thread event, for logging. */
onThreadEvent?: (event: CodexThreadEvent) => void
+ /** Treat the last agent_message as schema JSON. */
+ expectStructuredOutput?: boolean
}
/**
@@ -154,9 +161,11 @@ function buildUsage(usage: CodexUsage | undefined): TokenUsage | undefined {
* TOOL_CALL_START/ARGS/END + TOOL_CALL_RESULT sequences so UIs can render it
* while the TanStack engine never tries to execute them.
*
- * Codex reports assistant text and reasoning only as completed items (no
- * token-level deltas), so each `agent_message` / `reasoning` item becomes a
- * single START/CONTENT/END burst.
+ * Codex reports assistant text on `item.started` / `item.updated` /
+ * `item.completed`. Each `agent_message` streams as START/CONTENT/END, with
+ * CONTENT deltas from growing `item.text`. When `expectStructuredOutput` is
+ * set, the last agent message is also parsed as the schema object on
+ * `turn.completed`.
*
* Invariant: every TOOL_CALL_START is eventually paired with a
* TOOL_CALL_RESULT (synthesized as `{"status":"interrupted"}` when the run
@@ -237,30 +246,105 @@ export async function* translateThreadEvents(
unresolvedToolCalls.add(item.id)
}
- function* handleItemCompleted(item: CodexThreadItem): Generator {
- if (item.type === 'agent_message') {
- const messageId = item.id
- yield {
- type: EventType.TEXT_MESSAGE_START,
- messageId,
+ const openText = new Map()
+ let lastAgentMessage: { id: string; text: string } | undefined
+
+ function* startText(messageId: string): Generator {
+ if (openText.has(messageId)) return
+ openText.set(messageId, { emitted: 0, ended: false })
+ yield {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId,
+ model,
+ timestamp: now(),
+ role: 'assistant',
+ }
+ }
+
+ function* emitTextDelta(
+ messageId: string,
+ text: string,
+ ): Generator {
+ yield* startText(messageId)
+ const state = openText.get(messageId)
+ if (state === undefined || state.ended) return
+ if (text.length <= state.emitted) return
+ const delta = text.slice(state.emitted)
+ state.emitted = text.length
+ yield {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId,
+ model,
+ timestamp: now(),
+ delta,
+ content: text,
+ }
+ }
+
+ function* endText(messageId: string): Generator {
+ const state = openText.get(messageId)
+ if (state === undefined || state.ended) return
+ state.ended = true
+ yield {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId,
+ model,
+ timestamp: now(),
+ }
+ }
+
+ function* handleAgentMessage(
+ item: { id: string; text: string },
+ done: boolean,
+ ): Generator {
+ yield* emitTextDelta(item.id, item.text)
+ lastAgentMessage = { id: item.id, text: item.text }
+ if (done) yield* endText(item.id)
+ }
+
+ function* emitStructuredFromLast(): Generator {
+ if (ctx.expectStructuredOutput !== true) return
+ if (lastAgentMessage === undefined) return
+ const item = lastAgentMessage
+ lastAgentMessage = undefined
+ try {
+ const object = parseJsonFromAssistantText(item.text)
+ yield structuredOutputStartChunk({
+ messageId: item.id,
model,
- timestamp: now(),
- role: 'assistant',
- }
- yield {
- type: EventType.TEXT_MESSAGE_CONTENT,
- messageId,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId: item.id,
model,
- timestamp: now(),
- delta: item.text,
- content: item.text,
- }
+ threadId,
+ runId,
+ object,
+ raw: item.text,
+ })
+ } catch (error: unknown) {
+ const parserMessage =
+ error instanceof Error
+ ? error.message
+ : 'Invalid structured output JSON'
+ const preview = item.text.trim().slice(0, 200)
+ const message =
+ preview === '' ? parserMessage : `${parserMessage} Content: ${preview}`
yield {
- type: EventType.TEXT_MESSAGE_END,
- messageId,
+ type: EventType.RUN_ERROR,
model,
timestamp: now(),
+ message,
+ code: 'structured-output-parse-failed',
+ error: { message, code: 'structured-output-parse-failed' },
}
+ }
+ }
+
+ function* handleItemCompleted(item: CodexThreadItem): Generator {
+ if (item.type === 'agent_message') {
+ yield* handleAgentMessage(item, true)
} else if (item.type === 'reasoning') {
const reasoningId = item.id
yield {
@@ -334,13 +418,16 @@ export async function* translateThreadEvents(
// needs RUN_STARTED first.
yield* startRun()
- if (event.type === 'item.started') {
- if (isToolItem(event.item)) {
+ if (event.type === 'item.started' || event.type === 'item.updated') {
+ if (event.item.type === 'agent_message') {
+ yield* handleAgentMessage(event.item, false)
+ } else if (event.type === 'item.started' && isToolItem(event.item)) {
yield* openToolCall(event.item)
}
} else if (event.type === 'item.completed') {
yield* handleItemCompleted(event.item)
} else if (event.type === 'turn.completed') {
+ yield* emitStructuredFromLast()
yield* synthesizeUnresolvedResults()
const usage = buildUsage(event.usage)
yield {
@@ -366,10 +453,10 @@ export async function* translateThreadEvents(
error: { message },
}
}
- // turn.started and item.updated carry no state the chunk stream needs:
- // long-running items resolve via item.completed, and intermediate
- // updates (e.g. streaming command output) are intentionally dropped.
+ // turn.started carries no chunk-stream state. item.updated for tools
+ // (streaming command output, todo ticks) is dropped on purpose.
}
+ yield* emitStructuredFromLast()
} catch (error) {
// The run is dying (abort or SDK failure). Pair any started tool calls
// with a synthetic result first so the next request's pending-tool-call
diff --git a/packages/ai-codex/tests/text-adapter.test.ts b/packages/ai-codex/tests/text-adapter.test.ts
index 57b84b3d70..681635c370 100644
--- a/packages/ai-codex/tests/text-adapter.test.ts
+++ b/packages/ai-codex/tests/text-adapter.test.ts
@@ -201,4 +201,56 @@ describe('codex in-sandbox adapter', () => {
const err = chunks.find((c) => c.type === 'RUN_ERROR')
expect((err as { message?: string }).message).toMatch(/requires a sandbox/i)
})
+
+ it('passes --output-schema and emits structured-output.complete', async () => {
+ const fake = [
+ `import { writeFileSync } from 'node:fs'`,
+ `writeFileSync('codex-argv.txt', process.argv.join(' '))`,
+ `let input = ''`,
+ `process.stdin.on('data', (d) => { input += d })`,
+ `process.stdin.on('end', () => {`,
+ ` const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n')`,
+ ` w({ type: 'thread.started', thread_id: 'th-so' })`,
+ ` w({ type: 'turn.started' })`,
+ ` w({ type: 'item.completed', item: { id: 'i1', type: 'agent_message', text: '{"ok":true}' } })`,
+ ` w({ type: 'turn.completed', usage: { input_tokens: 1, output_tokens: 1 } })`,
+ `})`,
+ ].join('\n')
+
+ const sbx = await provider.create({})
+ await sbx.fs.write('/workspace/fake-codex.mjs', fake)
+
+ const adapter = codexText('gpt-5.5-codex', {
+ codexExecutable: 'node fake-codex.mjs',
+ })
+
+ const chunks = await collect(
+ adapter.chatStream({
+ model: 'gpt-5.5-codex',
+ messages: [{ role: 'user', content: 'summarize' }],
+ logger: noopLogger,
+ capabilities: capabilityContextWith(sbx),
+ outputSchema: {
+ type: 'object',
+ properties: { ok: { type: 'boolean' } },
+ required: ['ok'],
+ },
+ }),
+ )
+
+ const argv = await sbx.fs.read('/workspace/codex-argv.txt')
+ expect(argv).toContain('--output-schema')
+
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({ object: { ok: true } }),
+ )
+ }
+
+ await sbx.destroy()
+ })
})
diff --git a/packages/ai-codex/tests/translate.test.ts b/packages/ai-codex/tests/translate.test.ts
index b76e1d48fc..b6515eccde 100644
--- a/packages/ai-codex/tests/translate.test.ts
+++ b/packages/ai-codex/tests/translate.test.ts
@@ -36,6 +36,30 @@ async function collect(
return chunks
}
+function structuredComplete(chunks: Array) {
+ return chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+}
+
+function textDeltas(chunks: Array): string {
+ return chunks
+ .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT')
+ .map((c) => ('delta' in c ? c.delta : ''))
+ .join('')
+}
+
+function expectStructuredObject(
+ chunks: Array,
+ object: Record,
+) {
+ const complete = structuredComplete(chunks)
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(expect.objectContaining({ object }))
+ }
+}
+
const started: CodexThreadEvent = {
type: 'thread.started',
thread_id: 'sess-1',
@@ -451,4 +475,258 @@ describe('translateThreadEvents', () => {
'RUN_FINISHED',
])
})
+
+ it('emits structured-output events from the last agent_message when expected', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: '{"ok":true}' },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expect(
+ chunks.some(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.start',
+ ),
+ ).toBe(true)
+ const complete = structuredComplete(chunks)
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({ object: { ok: true }, raw: '{"ok":true}' }),
+ )
+ }
+ expect(textDeltas(chunks)).toBe('{"ok":true}')
+ })
+
+ it('keeps earlier agent_message text when only the last item is structured', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: 'working' },
+ },
+ {
+ type: 'item.completed',
+ item: { id: 'item-2', type: 'agent_message', text: '{"ok":true}' },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expect(textDeltas(chunks)).toContain('working')
+ expectStructuredObject(chunks, { ok: true })
+ })
+
+ it('emits RUN_ERROR when the last agent_message is not JSON', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: 'not json' },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expect(chunks.find((c) => c.type === 'RUN_ERROR')).toMatchObject({
+ type: 'RUN_ERROR',
+ code: 'structured-output-parse-failed',
+ message: expect.stringMatching(/not json/),
+ })
+ })
+
+ it('parses fenced JSON from the last agent_message', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: {
+ id: 'item-1',
+ type: 'agent_message',
+ text: '```json\n{"ok":true}\n```',
+ },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expectStructuredObject(chunks, { ok: true })
+ expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(false)
+ })
+
+ it('emits earlier agent_message text before the last structured item arrives', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: 'working' },
+ },
+ {
+ type: 'item.started',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ status: 'in_progress',
+ },
+ },
+ {
+ type: 'item.completed',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ aggregated_output: 'ok',
+ status: 'completed',
+ },
+ },
+ {
+ type: 'item.completed',
+ item: { id: 'item-2', type: 'agent_message', text: '{"ok":true}' },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ const types = chunks.map((c) =>
+ c.type === 'CUSTOM' ? `CUSTOM:${c.name}` : c.type,
+ )
+ const workingIdx = chunks.findIndex(
+ (c) => c.type === 'TEXT_MESSAGE_CONTENT' && c.delta === 'working',
+ )
+ const toolStartIdx = types.indexOf('TOOL_CALL_START')
+ const completeIdx = types.indexOf('CUSTOM:structured-output.complete')
+ const finishedIdx = types.indexOf('RUN_FINISHED')
+ expect(workingIdx).toBeGreaterThan(-1)
+ expect(toolStartIdx).toBeGreaterThan(workingIdx)
+ expect(completeIdx).toBeGreaterThan(toolStartIdx)
+ expect(completeIdx).toBeLessThan(finishedIdx)
+ })
+
+ it('keeps a JSON agent_message as structured output when a started tool completes after it', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.started',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ status: 'in_progress',
+ },
+ },
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: '{"ok":true}' },
+ },
+ {
+ type: 'item.completed',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ aggregated_output: 'ok',
+ status: 'completed',
+ },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expectStructuredObject(chunks, { ok: true })
+ const types = chunks.map((c) =>
+ c.type === 'CUSTOM' ? `CUSTOM:${c.name}` : c.type,
+ )
+ expect(types.indexOf('TOOL_CALL_START')).toBeLessThan(
+ types.indexOf('CUSTOM:structured-output.complete'),
+ )
+ expect(types.indexOf('TOOL_CALL_RESULT')).toBeLessThan(
+ types.indexOf('CUSTOM:structured-output.complete'),
+ )
+ })
+
+ it('keeps a JSON agent_message as structured output when tools follow it', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: '{"ok":true}' },
+ },
+ {
+ type: 'item.started',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ status: 'in_progress',
+ },
+ },
+ {
+ type: 'item.completed',
+ item: {
+ id: 'cmd-1',
+ type: 'command_execution',
+ command: 'ls',
+ aggregated_output: 'ok',
+ status: 'completed',
+ },
+ },
+ completedTurn,
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expectStructuredObject(chunks, { ok: true })
+ })
+
+ it('streams agent_message text from item.updated deltas', async () => {
+ const chunks = await collect([
+ started,
+ {
+ type: 'item.started',
+ item: { id: 'item-1', type: 'agent_message', text: 'Hel' },
+ },
+ {
+ type: 'item.updated',
+ item: { id: 'item-1', type: 'agent_message', text: 'Hello' },
+ },
+ {
+ type: 'item.updated',
+ item: { id: 'item-1', type: 'agent_message', text: 'Hello world' },
+ },
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: 'Hello world' },
+ },
+ completedTurn,
+ ])
+ const deltas = chunks
+ .filter((c) => c.type === 'TEXT_MESSAGE_CONTENT')
+ .map((c) => ('delta' in c ? c.delta : ''))
+ expect(deltas).toEqual(['Hel', 'lo', ' world'])
+ })
+
+ it('does not drop the last agent_message when the stream ends without turn.completed', async () => {
+ const chunks = await collect(
+ [
+ started,
+ {
+ type: 'item.completed',
+ item: { id: 'item-1', type: 'agent_message', text: '{"ok":true}' },
+ },
+ ],
+ makeCtx({ expectStructuredOutput: true }),
+ )
+ expectStructuredObject(chunks, { ok: true })
+ })
})
diff --git a/packages/ai-grok-build/src/adapters/text.ts b/packages/ai-grok-build/src/adapters/text.ts
index f8658aebe3..78ea35ce15 100644
--- a/packages/ai-grok-build/src/adapters/text.ts
+++ b/packages/ai-grok-build/src/adapters/text.ts
@@ -1,5 +1,11 @@
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
-import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
+import {
+ appendOutputSchemaInstruction,
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+ toRunErrorRawEvent,
+} from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
AsyncQueue,
@@ -27,7 +33,8 @@ import {
spawnNdjson,
} from '@tanstack/ai-sandbox'
import { buildPrompt } from '../messages/prompt'
-import { resolveGrokAcpAuthMethod } from '../auth'
+import { formatAcpRequestError, resolveGrokSessionAuthMethod } from '../auth'
+import type { GrokBuildAuthMode } from '../auth'
import { createGrokAcpNotificationHandler } from '../process/grok-acp-notifications'
import { openGrokAcpConnection } from '../process/acp'
import { resolveGrokExecutable } from '../process/resolve-executable'
@@ -84,9 +91,12 @@ export interface GrokBuildTextConfig {
/** ACP transport when `protocol` is `'acp'`. Defaults to `'auto'`. */
transport?: AcpTransportPreference
/**
- * ACP auth method (`xai.api_key` for API-key runs, `grok.com` for host login).
- * Defaults via {@link resolveGrokAcpAuthMethod}.
+ * `'api-key'` (default) calls authenticate with `xai.api_key`.
+ * `'host'` skips ACP authenticate (use `grok login`).
+ * Not inferred from the sandbox.
*/
+ authMode?: GrokBuildAuthMode
+ /** Explicit ACP auth method. Wins over {@link authMode}. */
authMethodId?: string
/** ACP permission policy. Defaults to `'bypassPermissions'`. */
permissionMode?: AcpPermissionMode
@@ -197,6 +207,14 @@ export class GrokBuildTextAdapter<
return `${exe} ${args.join(' ')}`
}
+ supportsCombinedToolsAndSchema(): boolean {
+ return true
+ }
+
+ combinedStructuredOutputSource(): 'event' {
+ return 'event'
+ }
+
private protocol(
options: TextOptions,
): GrokBuildProtocol {
@@ -356,13 +374,14 @@ export class GrokBuildTextAdapter<
modelOptions?.permissionMode ??
this.adapterConfig.permissionMode ??
'bypassPermissions'
- const authMethodId =
- modelOptions?.authMethodId ??
- this.adapterConfig.authMethodId ??
- resolveGrokAcpAuthMethod({
+ const authMethodId = resolveGrokSessionAuthMethod(
+ modelOptions?.authMode ?? this.adapterConfig.authMode,
+ modelOptions?.authMethodId ?? this.adapterConfig.authMethodId,
+ {
...process.env,
...this.adapterConfig.env,
- })
+ },
+ )
const queue = new AsyncQueue()
@@ -376,7 +395,7 @@ export class GrokBuildTextAdapter<
handle = await startAcpSession({
transport: connection.transport,
cwd: harnessCwd,
- authMethodId,
+ ...(authMethodId !== undefined && { authMethodId }),
...(sessionId !== undefined && { resumeSessionId: sessionId }),
...(bridge !== undefined && {
mcpServers: [
@@ -407,12 +426,18 @@ export class GrokBuildTextAdapter<
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
.map((p) => p.content)
.filter((c) => c.trim() !== '')
- const promptText = this.applySystemPrompts(
+ let promptText = this.applySystemPrompts(
systemPrompts,
session.resumed || sessionId === undefined
? resumePrompt
: buildPrompt(options.messages, undefined).prompt,
)
+ if (options.outputSchema) {
+ promptText = appendOutputSchemaInstruction(
+ promptText,
+ options.outputSchema,
+ )
+ }
session
.prompt(promptText)
@@ -426,7 +451,11 @@ export class GrokBuildTextAdapter<
})
.catch((error: unknown) => queue.fail(error))
- yield* mergeChunkStreams(
+ const wantsStructured = options.outputSchema !== undefined
+ let lastAssistantText = ''
+ let lastTextMessageId: string | undefined
+ let heldFinished: StreamChunk | undefined
+ for await (const chunk of mergeChunkStreams(
translateAcpStream(queue, {
model: this.model,
runId,
@@ -446,7 +475,36 @@ export class GrokBuildTextAdapter<
}),
}),
channel.stream,
- )
+ )) {
+ if (wantsStructured && chunk.type === EventType.RUN_FINISHED) {
+ heldFinished = chunk
+ continue
+ }
+ if (wantsStructured) {
+ if (chunk.type === EventType.TEXT_MESSAGE_START) {
+ lastAssistantText = ''
+ if (typeof chunk.messageId === 'string' && chunk.messageId !== '') {
+ lastTextMessageId = chunk.messageId
+ }
+ } else if (
+ chunk.type === EventType.TEXT_MESSAGE_CONTENT &&
+ typeof chunk.delta === 'string'
+ ) {
+ lastAssistantText += chunk.delta
+ }
+ }
+ yield chunk
+ }
+
+ if (options.outputSchema) {
+ yield* this.emitParsedStructuredOutput(
+ lastAssistantText,
+ threadId,
+ runId,
+ lastTextMessageId,
+ )
+ }
+ if (heldFinished) yield heldFinished
if (this.adapterConfig.emitDiff !== false) {
yield* this.emitDiffChunks(sandbox, cwd, threadId, runId)
@@ -454,6 +512,7 @@ export class GrokBuildTextAdapter<
} catch (error: unknown) {
const err = error as Error & { code?: string }
const rawEvent = toRunErrorRawEvent(error)
+ const message = formatAcpRequestError(error)
logger.errors('grok-build.chatStream fatal', {
error,
source: 'grok-build.chatStream',
@@ -462,11 +521,11 @@ export class GrokBuildTextAdapter<
type: EventType.RUN_ERROR,
model: options.model,
timestamp: Date.now(),
- message: err.message || 'Unknown error occurred',
+ message,
...(err.code !== undefined && { code: err.code }),
...(rawEvent !== undefined && { rawEvent }),
error: {
- message: err.message || 'Unknown error occurred',
+ message,
...(err.code !== undefined && { code: err.code }),
},
}
@@ -487,6 +546,43 @@ export class GrokBuildTextAdapter<
return `${systemPrompts.join('\n\n')}\n\n${prompt}`
}
+ private *emitParsedStructuredOutput(
+ raw: string,
+ threadId: string,
+ runId: string,
+ messageId = this.generateId(),
+ ): Generator {
+ try {
+ const object = parseJsonFromAssistantText(raw)
+ yield structuredOutputStartChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ object,
+ raw,
+ })
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Failed to parse structured output'
+ yield {
+ type: EventType.RUN_ERROR,
+ model: this.model,
+ timestamp: Date.now(),
+ message,
+ error: { message },
+ }
+ }
+ }
+
private async *emitDiffChunks(
sandbox: SandboxHandle,
cwd: string,
@@ -579,10 +675,16 @@ export class GrokBuildTextAdapter<
const systemPrompts = normalizeSystemPrompts(options.systemPrompts)
.map((p) => p.content)
.filter((c) => c.trim() !== '')
- const fullPrompt =
+ let fullPrompt =
systemPrompts.length > 0
? `${systemPrompts.join('\n\n')}\n\n${prompt}`
: prompt
+ if (options.outputSchema) {
+ fullPrompt = appendOutputSchemaInstruction(
+ fullPrompt,
+ options.outputSchema,
+ )
+ }
const exe = await resolveGrokExecutable(
sandbox,
@@ -664,6 +766,7 @@ export class GrokBuildTextAdapter<
parentRunId: options.parentRunId,
}),
genId,
+ ...(options.outputSchema ? { expectStructuredOutput: true } : {}),
onThreadEvent: (event) =>
logger.provider(`provider=grok-build type=${event.type}`, {
chunk: event,
@@ -689,6 +792,7 @@ export class GrokBuildTextAdapter<
} catch (error: unknown) {
const err = error as Error & { code?: string }
const rawEvent = toRunErrorRawEvent(error)
+ const message = formatAcpRequestError(error)
logger.errors('grok-build.chatStream fatal', {
error,
source: 'grok-build.chatStream',
@@ -697,11 +801,11 @@ export class GrokBuildTextAdapter<
type: EventType.RUN_ERROR,
model: options.model,
timestamp: Date.now(),
- message: err.message || 'Unknown error occurred',
+ message,
...(err.code !== undefined && { code: err.code }),
...(rawEvent !== undefined && { rawEvent }),
error: {
- message: err.message || 'Unknown error occurred',
+ message,
...(err.code !== undefined && { code: err.code }),
},
}
@@ -715,8 +819,8 @@ export class GrokBuildTextAdapter<
): Promise> {
return Promise.reject(
new Error(
- 'Structured output is not yet supported by the in-sandbox Grok Build adapter. ' +
- 'Use a model adapter (e.g. grok) for structured output, or omit outputSchema.',
+ 'This harness honors outputSchema on chat() in the same turn. ' +
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
),
)
}
diff --git a/packages/ai-grok-build/src/auth.ts b/packages/ai-grok-build/src/auth.ts
index edd47fb120..cfb3cff375 100644
--- a/packages/ai-grok-build/src/auth.ts
+++ b/packages/ai-grok-build/src/auth.ts
@@ -2,17 +2,55 @@
export type GrokBuildAcpAuthMethod = 'xai.api_key' | 'grok.com'
/**
- * Pick the Grok ACP auth method for {@link startAcpSession}.
+ * How the harness should sign in.
*
- * Sandboxed runs inject `XAI_API_KEY`; local runs may use `grok login` instead.
+ * - `'api-key'` (default): call `authenticate` with `xai.api_key`.
+ * - `'host'`: skip ACP `authenticate`. Use `grok login` on the machine.
+ *
+ * This is not inferred from the sandbox. A local-process run can be a laptop
+ * with a login, or a runner that only has `XAI_API_KEY`.
+ */
+export type GrokBuildAuthMode = 'host' | 'api-key'
+
+/** Isolated sandboxes have no host CLI login, so this is the default. */
+const DEFAULT_GROK_AUTH_MODE: GrokBuildAuthMode = 'api-key'
+
+/**
+ * Pick the Grok ACP auth method for {@link startAcpSession} when using
+ * `authMode: 'api-key'`.
*/
export function resolveGrokAcpAuthMethod(
env?: Record,
-): GrokBuildAcpAuthMethod {
+): GrokBuildAcpAuthMethod | undefined {
const key =
env?.XAI_API_KEY ??
env?.GROK_API_KEY ??
process.env.XAI_API_KEY ??
process.env.GROK_API_KEY
- return key ? 'xai.api_key' : 'grok.com'
+ return key ? 'xai.api_key' : undefined
+}
+
+export function resolveGrokSessionAuthMethod(
+ authMode: GrokBuildAuthMode | undefined,
+ explicitId: string | undefined,
+ env?: Record,
+): string | undefined {
+ if (explicitId !== undefined) return explicitId
+ const mode = authMode ?? DEFAULT_GROK_AUTH_MODE
+ if (mode === 'api-key') {
+ return resolveGrokAcpAuthMethod(env) ?? 'xai.api_key'
+ }
+ return undefined
+}
+
+/** Prefer ACP `RequestError.data` over the generic `Internal error` message. */
+export function formatAcpRequestError(error: unknown): string {
+ if (error !== null && typeof error === 'object' && 'data' in error) {
+ const data = error.data
+ if (typeof data === 'string' && data.trim() !== '') return data.trim()
+ }
+ if (error instanceof Error && error.message.trim() !== '') {
+ return error.message
+ }
+ return 'Unknown error occurred'
}
diff --git a/packages/ai-grok-build/src/index.ts b/packages/ai-grok-build/src/index.ts
index b182b0d877..76d9b1d2ff 100644
--- a/packages/ai-grok-build/src/index.ts
+++ b/packages/ai-grok-build/src/index.ts
@@ -29,3 +29,9 @@ export {
DEFAULT_GROK_ACP_PORT,
} from './process/acp'
export type { GrokBuildProtocol } from './provider-options'
+export type { GrokBuildAuthMode, GrokBuildAcpAuthMethod } from './auth'
+export {
+ resolveGrokAcpAuthMethod,
+ resolveGrokSessionAuthMethod,
+ formatAcpRequestError,
+} from './auth'
diff --git a/packages/ai-grok-build/src/provider-options.ts b/packages/ai-grok-build/src/provider-options.ts
index d671b588c5..6d9196d173 100644
--- a/packages/ai-grok-build/src/provider-options.ts
+++ b/packages/ai-grok-build/src/provider-options.ts
@@ -6,6 +6,7 @@ import type {
AcpPermissionMode,
AcpTransportPreference,
} from '@tanstack/ai-acp'
+import type { GrokBuildAuthMode } from './auth'
export type GrokBuildProtocol = 'acp' | 'streaming-json'
@@ -30,7 +31,13 @@ export interface GrokBuildTextProviderOptions {
/** ACP transport when `protocol` is `'acp'`. Defaults to `'auto'`. */
transport?: AcpTransportPreference
/**
- * ACP auth method (`xai.api_key` | `grok.com`). Omitted → auto from env keys.
+ * `'api-key'` (default) calls authenticate with `xai.api_key`.
+ * `'host'` skips ACP authenticate (use `grok login`).
+ * Not inferred from the sandbox.
+ */
+ authMode?: GrokBuildAuthMode
+ /**
+ * Explicit ACP auth method. Wins over {@link authMode}.
*/
authMethodId?: string
/** ACP permission policy for tool approvals. Defaults to `'bypassPermissions'`. */
diff --git a/packages/ai-grok-build/src/stream/translate.ts b/packages/ai-grok-build/src/stream/translate.ts
index b54116fa4e..885565287e 100644
--- a/packages/ai-grok-build/src/stream/translate.ts
+++ b/packages/ai-grok-build/src/stream/translate.ts
@@ -1,4 +1,9 @@
import { EventType, buildBaseUsage } from '@tanstack/ai'
+import {
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+} from '@tanstack/ai/adapter-internals'
import { GrokThoughtRouter } from './thought-router'
import type { StreamChunk, TokenUsage } from '@tanstack/ai'
import type {
@@ -23,6 +28,8 @@ export interface TranslateContext {
onSessionId?: (sessionId: string) => void
/** Called for each raw harness event, for logging. */
onThreadEvent?: (event: GrokBuildStreamEvent) => void
+ /** Parse accumulated assistant text as schema JSON at run end. */
+ expectStructuredOutput?: boolean
}
/**
@@ -153,6 +160,7 @@ export async function* translateThreadEvents(
let assistantMessageId: string | null = null
let reasoningMessageId: string | null = null
let thoughtRouter: GrokThoughtRouter | null = null
+ let assistantText = ''
const unresolvedToolCalls = new Set()
const openedToolItems = new Set()
@@ -255,12 +263,45 @@ export async function* translateThreadEvents(
model,
timestamp: now(),
}
+ assistantText += event.data
break
}
case 'end': {
yield* getThoughtRouter().finalize()
yield* closeReasoning()
yield* closeAssistant()
+ if (ctx.expectStructuredOutput === true) {
+ try {
+ const object = parseJsonFromAssistantText(assistantText)
+ const messageId = genId()
+ yield structuredOutputStartChunk({
+ messageId,
+ model,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId,
+ model,
+ threadId,
+ runId,
+ object,
+ raw: assistantText,
+ })
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Failed to parse structured output'
+ yield {
+ type: EventType.RUN_ERROR,
+ model,
+ timestamp: now(),
+ message,
+ error: { message },
+ }
+ }
+ }
if (event.sessionId) {
onSessionId?.(event.sessionId)
yield {
diff --git a/packages/ai-grok-build/tests/acp.test.ts b/packages/ai-grok-build/tests/acp.test.ts
index 3768970e07..9f3d35e2d5 100644
--- a/packages/ai-grok-build/tests/acp.test.ts
+++ b/packages/ai-grok-build/tests/acp.test.ts
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it } from 'vitest'
-import { resolveGrokAcpAuthMethod } from '../src/auth'
+import {
+ formatAcpRequestError,
+ resolveGrokAcpAuthMethod,
+ resolveGrokSessionAuthMethod,
+} from '../src/auth'
import {
buildGrokAcpServeCommand,
buildGrokAcpStdioCommand,
@@ -19,8 +23,47 @@ describe('resolveGrokAcpAuthMethod', () => {
)
})
- it('falls back to grok.com for host login flows', () => {
- expect(resolveGrokAcpAuthMethod()).toBe('grok.com')
+ it('omits auth when no API key is set so host login can win', () => {
+ expect(resolveGrokAcpAuthMethod()).toBeUndefined()
+ })
+})
+
+describe('resolveGrokSessionAuthMethod', () => {
+ it('skips authenticate on host mode even when an API key is set', () => {
+ process.env.XAI_API_KEY = 'sk-test'
+ expect(resolveGrokSessionAuthMethod('host', undefined)).toBeUndefined()
+ })
+
+ it('uses xai.api_key on api-key mode', () => {
+ process.env.XAI_API_KEY = 'sk-test'
+ expect(resolveGrokSessionAuthMethod('api-key', undefined)).toBe(
+ 'xai.api_key',
+ )
+ })
+
+ it('defaults omitted authMode to api-key', () => {
+ expect(resolveGrokSessionAuthMethod(undefined, undefined)).toBe(
+ 'xai.api_key',
+ )
+ })
+
+ it('lets an explicit authMethodId win', () => {
+ expect(resolveGrokSessionAuthMethod('host', 'grok.com')).toBe('grok.com')
+ })
+})
+
+describe('formatAcpRequestError', () => {
+ it('prefers RequestError.data over Internal error', () => {
+ const error = Object.assign(new Error('Internal error'), {
+ data: 'Unauthorized (401) from https://cli-chat-proxy.grok.com/v1/responses',
+ })
+ expect(formatAcpRequestError(error)).toMatch(/Unauthorized \(401\)/)
+ })
+
+ it('falls back to Error.message when data is missing', () => {
+ expect(formatAcpRequestError(new Error('stream broke'))).toBe(
+ 'stream broke',
+ )
})
})
diff --git a/packages/ai-grok-build/tests/durability-protocol-warning.test.ts b/packages/ai-grok-build/tests/durability-protocol-warning.test.ts
index f154fccc58..4a5ad999cc 100644
--- a/packages/ai-grok-build/tests/durability-protocol-warning.test.ts
+++ b/packages/ai-grok-build/tests/durability-protocol-warning.test.ts
@@ -90,9 +90,10 @@ new AgentSideConnection((conn) => ({
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: { loadSession: true },
- // 'grok.com' matches \`resolveGrokAcpAuthMethod\`'s fallback when no
- // XAI_API_KEY/GROK_API_KEY env is set, so the real handshake picks it.
- authMethods: [{ id: 'grok.com', name: 'grok.com', description: null }],
+ authMethods: [
+ { id: 'xai.api_key', name: 'xai.api_key', description: null },
+ { id: 'grok.com', name: 'grok.com', description: null },
+ ],
}
},
async authenticate() {
diff --git a/packages/ai-grok-build/tests/translate.test.ts b/packages/ai-grok-build/tests/translate.test.ts
index 5bb4619a7b..04dd0b614c 100644
--- a/packages/ai-grok-build/tests/translate.test.ts
+++ b/packages/ai-grok-build/tests/translate.test.ts
@@ -5,16 +5,19 @@ import type { StreamChunk } from '@tanstack/ai'
async function collect(
events: Array,
+ expectStructuredOutput = false,
): Promise> {
async function* source() {
for (const event of events) yield event
}
+ let n = 0
const out: Array = []
for await (const chunk of translateThreadEvents(source(), {
model: 'grok-build',
runId: 'run-1',
threadId: 'thread-1',
- genId: () => 'gen-id',
+ genId: () => `gen-${++n}`,
+ ...(expectStructuredOutput ? { expectStructuredOutput: true } : {}),
})) {
out.push(chunk)
}
@@ -57,6 +60,46 @@ describe('translateThreadEvents (native grok streaming-json)', () => {
).toBe(true)
})
+ it('emits structured-output events from accumulated text when expected', async () => {
+ const chunks = await collect(
+ [
+ { type: 'text', data: '{"ok":true}' },
+ {
+ type: 'end',
+ stopReason: 'EndTurn',
+ sessionId: 'sess-so',
+ requestId: 'req-1',
+ },
+ ],
+ true,
+ )
+ const complete = chunks.find(
+ (c) => c.type === 'CUSTOM' && c.name === 'structured-output.complete',
+ )
+ expect(complete).toBeDefined()
+ if (complete?.type === 'CUSTOM') {
+ expect(complete.value).toEqual(
+ expect.objectContaining({ object: { ok: true } }),
+ )
+ }
+ })
+
+ it('emits RUN_ERROR when expected structured text is not JSON', async () => {
+ const chunks = await collect(
+ [
+ { type: 'text', data: 'not json' },
+ {
+ type: 'end',
+ stopReason: 'EndTurn',
+ sessionId: 'sess-so',
+ requestId: 'req-1',
+ },
+ ],
+ true,
+ )
+ expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(true)
+ })
+
it('surfaces native error events as RUN_ERROR', async () => {
const chunks = await collect([{ type: 'error', message: 'bad model' }])
expect(chunks.some((c) => c.type === 'RUN_ERROR')).toBe(true)
diff --git a/packages/ai-opencode/src/adapters/text.ts b/packages/ai-opencode/src/adapters/text.ts
index de3650103c..1ee74d4c66 100644
--- a/packages/ai-opencode/src/adapters/text.ts
+++ b/packages/ai-opencode/src/adapters/text.ts
@@ -1,5 +1,11 @@
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
-import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
+import {
+ appendOutputSchemaInstruction,
+ parseJsonFromAssistantText,
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+ toRunErrorRawEvent,
+} from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
DurableAttachNotSupportedError,
@@ -110,6 +116,14 @@ export class OpencodeTextAdapter<
return getSandbox(ctx)
}
+ supportsCombinedToolsAndSchema(): boolean {
+ return true
+ }
+
+ combinedStructuredOutputSource(): 'event' {
+ return 'event'
+ }
+
private applySystemPrompts(
options: TextOptions,
prompt: string,
@@ -321,22 +335,32 @@ export class OpencodeTextAdapter<
queue.push({ kind: 'session', sessionId: session.sessionId })
- const promptText = this.applySystemPrompts(
+ let promptText = this.applySystemPrompts(
options,
session.resumed || sessionId === undefined
? resumePrompt
: buildPrompt(options.messages, undefined).prompt,
)
+ if (options.outputSchema) {
+ promptText = appendOutputSchemaInstruction(
+ promptText,
+ options.outputSchema,
+ )
+ }
+ let lastAssistantText = ''
session
.prompt(promptText)
- .then(({ message }) => {
+ .then(({ message, text }) => {
+ lastAssistantText = text
queue.push({ kind: 'done', message })
queue.end()
})
.catch((error: unknown) => queue.fail(error))
- yield* mergeChunkStreams(
+ let heldFinished: StreamChunk | undefined
+ let lastTextMessageId: string | undefined
+ for await (const chunk of mergeChunkStreams(
translateOpencodeStream(queue, {
model: this.model,
runId,
@@ -352,7 +376,54 @@ export class OpencodeTextAdapter<
}),
}),
channel.stream,
- )
+ )) {
+ if (options.outputSchema && chunk.type === EventType.RUN_FINISHED) {
+ heldFinished = chunk
+ continue
+ }
+ if (
+ chunk.type === EventType.TEXT_MESSAGE_START &&
+ typeof chunk.messageId === 'string' &&
+ chunk.messageId !== ''
+ ) {
+ lastTextMessageId = chunk.messageId
+ }
+ yield chunk
+ }
+
+ if (options.outputSchema) {
+ try {
+ const object = parseJsonFromAssistantText(lastAssistantText)
+ const messageId = lastTextMessageId ?? this.generateId()
+ yield structuredOutputStartChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ })
+ yield structuredOutputCompleteChunk({
+ messageId,
+ model: this.model,
+ threadId,
+ runId,
+ object,
+ raw: lastAssistantText,
+ })
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : 'Failed to parse structured output'
+ yield {
+ type: EventType.RUN_ERROR,
+ model: this.model,
+ timestamp: Date.now(),
+ message,
+ error: { message },
+ }
+ }
+ }
+ if (heldFinished) yield heldFinished
// Surface pending approval requests (ask-policy actions awaiting a client
// decision); the client approves and re-runs to continue.
@@ -392,8 +463,8 @@ export class OpencodeTextAdapter<
): Promise> {
return Promise.reject(
new Error(
- 'Structured output is not yet supported by the in-sandbox OpenCode adapter. ' +
- 'Use a model adapter for structured output, or omit outputSchema.',
+ 'This harness honors outputSchema on chat() in the same turn. ' +
+ 'Pass outputSchema to chat(), or use a model adapter for a one-shot extract.',
),
)
}
diff --git a/packages/ai-opencode/tests/text-adapter.test.ts b/packages/ai-opencode/tests/text-adapter.test.ts
index 0ab5f0fce6..00d9a0a172 100644
--- a/packages/ai-opencode/tests/text-adapter.test.ts
+++ b/packages/ai-opencode/tests/text-adapter.test.ts
@@ -94,6 +94,12 @@ describe('startOpencodeServerInSandbox', () => {
})
describe('opencode adapter', () => {
+ it('opts into combined event-source structured output', () => {
+ const adapter = opencodeText('anthropic/claude-sonnet-4-5')
+ expect(adapter.supportsCombinedToolsAndSchema()).toBe(true)
+ expect(adapter.combinedStructuredOutputSource()).toBe('event')
+ })
+
it('requires a sandbox capability', async () => {
const adapter = opencodeText('anthropic/claude-sonnet-4-5')
const result = await collect(
diff --git a/packages/ai-sandbox-docker/src/handle.ts b/packages/ai-sandbox-docker/src/handle.ts
index 3eb5392540..b35f18852d 100644
--- a/packages/ai-sandbox-docker/src/handle.ts
+++ b/packages/ai-sandbox-docker/src/handle.ts
@@ -430,9 +430,15 @@ export class DockerHandle implements SandboxHandle {
}
private envArray(extra?: Record): Array {
- return Object.entries({ ...this.envVars, ...extra }).map(
- ([k, v]) => `${k}=${v}`,
- )
+ // Docker `exec` Env replaces the container env. Keep a PATH and HOME so
+ // CLIs still resolve and `~/.claude` has a home when secrets are the only
+ // other vars.
+ return Object.entries({
+ PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
+ HOME: '/root',
+ ...this.envVars,
+ ...extra,
+ }).map(([k, v]) => `${k}=${v}`)
}
/**
diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md
index 6126921fcc..a041a261a7 100644
--- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md
+++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md
@@ -20,6 +20,7 @@ sources:
- 'TanStack/ai:docs/structured-outputs/streaming.md'
- 'TanStack/ai:docs/structured-outputs/multi-turn.md'
- 'TanStack/ai:docs/structured-outputs/with-tools.md'
+ - 'TanStack/ai:docs/structured-outputs/harnesses.md'
---
# Structured Outputs
@@ -48,7 +49,7 @@ person.age // number
When `outputSchema` is provided, `chat()` returns `Promise>` instead of `AsyncIterable`. The result is fully typed.
-Adding `stream: true` switches the return to `StructuredOutputStream>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, and **Pattern 5** for multi-turn structured chats.
+Adding `stream: true` switches the return to `StructuredOutputStream>` — incremental JSON deltas plus a terminal validated object. See **Pattern 3** below for direct iteration, **Pattern 4** for the `useChat` shape on the client, **Pattern 5** for multi-turn structured chats, and **Pattern 6** for harness adapters.
## Decision: which pattern fits
@@ -59,6 +60,7 @@ Adding `stream: true` switches the return to `StructuredOutputStream }` so the model sees its own prior structured response. Streaming / errored parts are dropped from the round-trip.
+### Pattern 6: Harness adapters (Claude Code, Codex, OpenCode, Grok Build, ACP)
+
+Dedicated harness adapters honor `chat({ outputSchema })` on the same turn. Native harness tools still run. Read the object from `await chat()`, from `useChat().final`, or from the assistant `structured-output` part on `messages[].parts`. Do not parse assistant prose.
+
+A UI endpoint must pass `stream: true`. Without it, `chat()` returns a `Promise`, not SSE.
+
+```typescript
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { claudeCodeText } from '@tanstack/ai-claude-code'
+import { withSandbox } from '@tanstack/ai-sandbox'
+import { z } from 'zod'
+import { sandbox } from './sandbox'
+
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+})
+
+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)
+}
+```
+
+```tsx
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { z } from 'zod'
+
+const ReportSchema = z.object({
+ name: z.string(),
+ oneLiner: z.string(),
+})
+
+const { final } = useChat({
+ connection: fetchServerSentEvents('/api/repo-report'),
+ outputSchema: ReportSchema,
+})
+
+final?.name
+```
+
+- Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
+- `partial` stays empty until `structured-output.complete`.
+- Client tools and `needsApproval` fail fast. The harness cannot pause for a browser round-trip.
+- Render live work from `messages[].parts` (`thinking`, `tool-call`, `text`, `structured-output`). `final` is only the latest turn.
+- See [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md).
+
## Common Mistakes
### HIGH: Filtering `TextPart`s out of `useChat` renderers when using `outputSchema`
@@ -509,4 +578,5 @@ provider call, stripping system prompts), use the dedicated
- See also: **ai-core/chat-experience/SKILL.md** — Base `useChat` surface; the structured-output additions documented here layer on top.
- See also: **ai-core/adapter-configuration/SKILL.md** — Adapter handles structured-output strategy transparently.
- See also: **ai-core/tool-calling/SKILL.md** — Combine `tools` with `outputSchema` for an agent loop that runs tools first and returns a typed object. Tool-approval and client-tool flows compose with structured runs without extra wiring; see [docs/structured-outputs/with-tools.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/with-tools.md).
+- See also: [docs/structured-outputs/harnesses.md](https://github.com/TanStack/ai/blob/main/docs/structured-outputs/harnesses.md) — dedicated harness adapters and `useChat().final`.
- See also: **ai-core/middleware/SKILL.md** — `onStructuredOutputConfig` hook and the `structuredOutput` phase for observing/transforming the final structured-output call.
diff --git a/packages/ai/src/activities/chat/adapter.ts b/packages/ai/src/activities/chat/adapter.ts
index cb3bcae6df..d461afb669 100644
--- a/packages/ai/src/activities/chat/adapter.ts
+++ b/packages/ai/src/activities/chat/adapter.ts
@@ -159,6 +159,20 @@ export interface TextAdapter<
supportsCombinedToolsAndSchema?: (
modelOptions?: TProviderOptions | undefined,
) => boolean
+
+ /**
+ * Where native-combined structured output is taken from.
+ *
+ * - `'text'` (default when omitted): the agent loop's accumulated
+ * assistant text is schema JSON. The engine parses it after the loop.
+ * HTTP adapters use this.
+ * - `'event'`: the adapter emits `structured-output.complete` during
+ * `chatStream`. The engine must not parse accumulated prose. Harness
+ * adapters use this.
+ */
+ combinedStructuredOutputSource?: (
+ modelOptions?: TProviderOptions | undefined,
+ ) => 'text' | 'event'
}
/**
diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts
index 2130c6be03..8c7da926c6 100644
--- a/packages/ai/src/activities/chat/index.ts
+++ b/packages/ai/src/activities/chat/index.ts
@@ -655,11 +655,12 @@ interface TextEngineConfig<
* - nativeCombined: when true, the adapter declared
* `supportsCombinedToolsAndSchema()` and the engine wires `jsonSchema`
* into the regular `chatStream` call instead of running a separate
- * finalization round-trip. The agent loop's final-turn text is the
- * schema-constrained JSON; the engine parses it from accumulated
- * content. The `'structuredOutput'` middleware phase does NOT fire on
- * this path — middleware sees the run through `beforeModel` /
- * `modelStream` as usual.
+ * finalization round-trip. The `'structuredOutput'` middleware phase
+ * does NOT fire on this path — middleware sees the run through
+ * `beforeModel` / `modelStream` as usual.
+ * - source: how to take the combined object. `'text'` (default) parses
+ * accumulated assistant text. `'event'` reads an adapter-emitted
+ * `structured-output.complete` and does not parse prose.
*/
finalStructuredOutput?: {
jsonSchema: JSONSchema
@@ -667,6 +668,7 @@ interface TextEngineConfig<
normalize?: (data: unknown) => unknown
validate?: (data: unknown) => unknown
nativeCombined?: boolean
+ source?: 'text' | 'event'
}
}
@@ -801,12 +803,14 @@ class TextEngine<
code?: string
cause?: unknown
} | null = null
+ private combinedCompleteEmitted = false
private readonly finalStructuredOutput?: {
jsonSchema: JSONSchema
yieldChunks: boolean
normalize?: (data: unknown) => unknown
validate?: (data: unknown) => unknown
nativeCombined?: boolean
+ source?: 'text' | 'event'
}
constructor(
@@ -1110,7 +1114,8 @@ class TextEngine<
this.finalStructuredOutput &&
this.toolPhase !== 'wait' &&
!this.isCancelled() &&
- !this.finalizationError
+ !this.finalizationError &&
+ !this.earlyTermination
) {
if (this.finalStructuredOutput.nativeCombined === true) {
yield* this.harvestCombinedStructuredOutput()
@@ -1374,9 +1379,46 @@ class TextEngine<
// text starting — intermediate tool-call iterations don't need it,
// and emitting at run-start would wrap tool-call commentary into a
// structured-output part too.
+ if (
+ chunk.type === EventType.CUSTOM &&
+ chunk.name === 'structured-output.start'
+ ) {
+ this.combinedStartEmitted = true
+ const startValue = chunk.value
+ if (
+ startValue &&
+ typeof startValue === 'object' &&
+ 'messageId' in startValue &&
+ typeof startValue.messageId === 'string'
+ ) {
+ this.combinedStructuredMessageId = startValue.messageId
+ }
+ }
+
+ let outboundChunk: StreamChunk = chunk
+ if (
+ this.finalStructuredOutput?.source === 'event' &&
+ chunk.type === EventType.CUSTOM &&
+ chunk.name === 'structured-output.complete'
+ ) {
+ const parsed = readStructuredOutputCompleteValue(chunk.value)
+ if (parsed) {
+ const object = this.finalStructuredOutput.normalize
+ ? this.finalStructuredOutput.normalize(parsed.object)
+ : parsed.object
+ this.structuredOutputResult = { data: object, rawText: parsed.raw }
+ this.combinedCompleteEmitted = true
+ const value = chunk.value
+ if (object !== parsed.object && value && typeof value === 'object') {
+ outboundChunk = { ...chunk, value: { ...value, object } }
+ }
+ }
+ }
+
if (
this.finalStructuredOutput?.nativeCombined === true &&
this.finalStructuredOutput.yieldChunks &&
+ this.finalStructuredOutput.source !== 'event' &&
!this.combinedStartEmitted &&
chunk.type === EventType.TEXT_MESSAGE_START
) {
@@ -1408,7 +1450,7 @@ class TextEngine<
// Pipe chunk through middleware (devtools middleware observes; strip-to-spec cleans)
const outputChunks = await this.middlewareRunner.runOnChunk(
this.middlewareCtx,
- chunk,
+ outboundChunk,
)
// When a streaming structured-output finalization step will run after
// the agent loop, suppress the agent-loop's RUN_STARTED/RUN_FINISHED
@@ -1554,9 +1596,23 @@ class TextEngine<
}
private handleRunErrorEvent(
- _chunk: Extract,
+ chunk: Extract,
): void {
this.earlyTermination = true
+ if (this.finalStructuredOutput && this.finalizationError === null) {
+ const message =
+ chunk.message ||
+ chunk.error?.message ||
+ 'Run failed before structured output completed'
+ this.finalizationError = {
+ message,
+ ...(chunk.code !== undefined
+ ? { code: chunk.code }
+ : chunk.error?.code !== undefined
+ ? { code: chunk.error.code }
+ : {}),
+ }
+ }
}
private finalizeCurrentThinkingStep(): void {
@@ -3141,35 +3197,46 @@ class TextEngine<
}
const yieldChunks = this.finalStructuredOutput.yieldChunks
- const rawText = this.accumulatedContent
+ const source = this.finalStructuredOutput.source ?? 'text'
- // Empty final-turn text means the agent loop terminated without the
- // model emitting any assistant content (e.g. early termination after
- // tool calls). Mirror the fallback path's "missing structured result"
- // error rather than silently returning undefined.
- if (rawText.length === 0) {
- this.finalizationError = {
- message: 'missing structured result',
- code: 'structured-output-missing-result',
+ if (source === 'event') {
+ if (!this.structuredOutputResult) {
+ this.finalizationError = {
+ message: 'missing structured result',
+ code: 'structured-output-missing-result',
+ }
}
} else {
- try {
- const parsed: unknown = JSON.parse(rawText)
- // Normalize (un-widen) before storing so the synthesized
- // structured-output.complete chunk and the Promise result both
- // carry the cleaned payload. JSON.parse preserves provider nulls, so
- // this is where native-combined output gets its widening undone.
- const data = this.finalStructuredOutput.normalize
- ? this.finalStructuredOutput.normalize(parsed)
- : parsed
- this.structuredOutputResult = { data, rawText }
- } catch (err: unknown) {
- const detail =
- rawText.slice(0, 200) + (rawText.length > 200 ? '...' : '')
+ const rawText = this.accumulatedContent
+
+ // Empty final-turn text means the agent loop terminated without the
+ // model emitting any assistant content (e.g. early termination after
+ // tool calls). Mirror the fallback path's "missing structured result"
+ // error rather than silently returning undefined.
+ if (rawText.length === 0) {
this.finalizationError = {
- message: `Failed to parse structured output as JSON. Content: ${detail}`,
- code: 'structured-output-parse-failed',
- cause: err,
+ message: 'missing structured result',
+ code: 'structured-output-missing-result',
+ }
+ } else {
+ try {
+ const parsed: unknown = JSON.parse(rawText)
+ // Normalize (un-widen) before storing so the synthesized
+ // structured-output.complete chunk and the Promise result both
+ // carry the cleaned payload. JSON.parse preserves provider nulls, so
+ // this is where native-combined output gets its widening undone.
+ const data = this.finalStructuredOutput.normalize
+ ? this.finalStructuredOutput.normalize(parsed)
+ : parsed
+ this.structuredOutputResult = { data, rawText }
+ } catch (err: unknown) {
+ const detail =
+ rawText.slice(0, 200) + (rawText.length > 200 ? '...' : '')
+ this.finalizationError = {
+ message: `Failed to parse structured output as JSON. Content: ${detail}`,
+ code: 'structured-output-parse-failed',
+ cause: err,
+ }
}
}
}
@@ -3239,7 +3306,11 @@ class TextEngine<
// complete event yields AFTER the loop ends, by which point
// `getActiveAssistantMessageId()` returns null and would otherwise drop
// the event silently).
- if (this.structuredOutputResult && !this.finalizationError) {
+ if (
+ this.structuredOutputResult &&
+ !this.finalizationError &&
+ !this.combinedCompleteEmitted
+ ) {
const completeChunk: StreamChunk = {
type: EventType.CUSTOM,
name: 'structured-output.complete',
@@ -3892,6 +3963,8 @@ async function runAgenticStructuredOutput<
// agent loop's accumulated final-turn text.
const nativeCombined =
adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
+ const source =
+ adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? 'text'
const mcpManager = MCPManager.from(mcp)
const mcpTools = await mcpManager.discover()
@@ -3915,6 +3988,7 @@ async function runAgenticStructuredOutput<
normalize,
...(validate ? { validate } : {}),
...(nativeCombined ? { nativeCombined: true } : {}),
+ source,
},
},
logger,
@@ -4207,6 +4281,8 @@ async function* runStreamingStructuredOutputImpl<
// does not fire.
const nativeCombined =
adapter.supportsCombinedToolsAndSchema?.(options.modelOptions) === true
+ const source =
+ adapter.combinedStructuredOutputSource?.(options.modelOptions) ?? 'text'
const mcpManager = MCPManager.from(mcp)
const mcpTools = await mcpManager.discover()
@@ -4231,6 +4307,7 @@ async function* runStreamingStructuredOutputImpl<
yieldChunks: true,
normalize,
...(nativeCombined ? { nativeCombined: true } : {}),
+ source,
},
},
logger,
diff --git a/packages/ai/src/activities/chat/stream/processor.ts b/packages/ai/src/activities/chat/stream/processor.ts
index 1994a5b37c..aa67f808b0 100644
--- a/packages/ai/src/activities/chat/stream/processor.ts
+++ b/packages/ai/src/activities/chat/stream/processor.ts
@@ -1854,9 +1854,10 @@ export class StreamProcessor {
if (chunk.name === 'structured-output.start' && chunk.value) {
const v = chunk.value as { messageId?: string }
- const targetId = v.messageId ?? messageId
+ const { messageId: targetId } = this.ensureAssistantMessage(
+ v.messageId ?? messageId ?? undefined,
+ )
if (targetId) {
- this.ensureAssistantMessage(targetId)
this.structuredMessageIds.add(targetId)
this.structuredOutputUpdateBatches.delete(targetId)
this.events.onStructuredOutputChange?.({
@@ -1876,7 +1877,9 @@ export class StreamProcessor {
reasoning?: string
messageId?: string
}
- const targetId = v.messageId ?? messageId
+ const { messageId: targetId } = this.ensureAssistantMessage(
+ v.messageId ?? messageId ?? undefined,
+ )
if (targetId) {
this.flushStructuredOutputUpdate(targetId)
this.messages = completeStructuredOutputPart(
diff --git a/packages/ai/src/adapter-internals.ts b/packages/ai/src/adapter-internals.ts
index b0c2612bb6..ddcb6724f2 100644
--- a/packages/ai/src/adapter-internals.ts
+++ b/packages/ai/src/adapter-internals.ts
@@ -25,3 +25,11 @@ export {
PendingTurnCapability,
providePendingTurn,
} from './activities/chat/middleware/pending-turn'
+export {
+ appendOutputSchemaInstruction,
+ parseJsonFromAssistantText,
+} from './utilities/structured-output-text'
+export {
+ structuredOutputCompleteChunk,
+ structuredOutputStartChunk,
+} from './utilities/structured-output-events'
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 49e7928a99..b73fafb667 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -958,10 +958,13 @@ export interface TextOptions<
* `supportsCombinedToolsAndSchema(modelOptions) === true`. The adapter
* should then wire the schema into the upstream request (e.g.
* `response_format: { type: 'json_schema', ... }`, `text.format`,
- * `output_format`) alongside any `tools`. The model's natural final
- * turn carries the schema-constrained JSON text and the engine
- * harvests it from the agent loop without a separate finalization
- * round-trip.
+ * `output_format`, `--json-schema`) alongside any `tools`.
+ *
+ * How the engine then takes the object depends on
+ * `combinedStructuredOutputSource()`:
+ * - `'text'` (default): the final-turn assistant text is the JSON.
+ * - `'event'`: the adapter emits `structured-output.complete` during
+ * `chatStream`. Accumulated prose is not parsed.
*
* Adapters that did NOT declare the capability never see this field
* populated — the engine instead invokes `structuredOutput` /
diff --git a/packages/ai/src/utilities/structured-output-events.ts b/packages/ai/src/utilities/structured-output-events.ts
new file mode 100644
index 0000000000..6f7daa64ac
--- /dev/null
+++ b/packages/ai/src/utilities/structured-output-events.ts
@@ -0,0 +1,44 @@
+import { EventType } from '../types'
+import type { StreamChunk } from '../types'
+
+export function structuredOutputStartChunk(args: {
+ messageId: string
+ model: string
+ threadId: string
+ runId: string
+ timestamp?: number
+}): StreamChunk {
+ return {
+ type: EventType.CUSTOM,
+ name: 'structured-output.start',
+ value: { messageId: args.messageId },
+ model: args.model,
+ timestamp: args.timestamp ?? Date.now(),
+ threadId: args.threadId,
+ runId: args.runId,
+ }
+}
+
+export function structuredOutputCompleteChunk(args: {
+ messageId: string
+ model: string
+ threadId: string
+ runId: string
+ object: unknown
+ raw: string
+ timestamp?: number
+}): StreamChunk {
+ return {
+ type: EventType.CUSTOM,
+ name: 'structured-output.complete',
+ value: {
+ object: args.object,
+ raw: args.raw,
+ messageId: args.messageId,
+ },
+ model: args.model,
+ timestamp: args.timestamp ?? Date.now(),
+ threadId: args.threadId,
+ runId: args.runId,
+ }
+}
diff --git a/packages/ai/src/utilities/structured-output-text.ts b/packages/ai/src/utilities/structured-output-text.ts
new file mode 100644
index 0000000000..ba3d98ca86
--- /dev/null
+++ b/packages/ai/src/utilities/structured-output-text.ts
@@ -0,0 +1,63 @@
+/**
+ * Parse JSON from a model/harness assistant string.
+ * Strips a wrapping markdown fence when the whole payload is fenced.
+ * If the model wrote prose first, take the last JSON object or array.
+ */
+export function parseJsonFromAssistantText(raw: string): unknown {
+ const trimmed = raw.trim()
+ if (trimmed === '') {
+ throw new SyntaxError('Assistant text is empty')
+ }
+
+ const candidates: Array = []
+ const wholeFence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/)
+ if (wholeFence?.[1]) candidates.push(wholeFence[1].trim())
+ candidates.push(trimmed)
+ const lastFence = [
+ ...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)\s*```/g),
+ ].at(-1)
+ if (lastFence?.[1]) candidates.push(lastFence[1].trim())
+ const extracted = extractLastJsonSlice(trimmed)
+ if (extracted !== undefined) candidates.push(extracted)
+
+ let lastError: unknown
+ for (const candidate of candidates) {
+ try {
+ return JSON.parse(candidate)
+ } catch (error) {
+ lastError = error
+ }
+ }
+ throw lastError instanceof Error
+ ? lastError
+ : new SyntaxError('No JSON object found in assistant text')
+}
+
+function extractLastJsonSlice(text: string): string | undefined {
+ for (let end = text.length - 1; end >= 0; end--) {
+ if (text[end] !== '}' && text[end] !== ']') continue
+ for (let start = end; start >= 0; start--) {
+ const opener = text[start]
+ if (opener !== '{' && opener !== '[') continue
+ const slice = text.slice(start, end + 1)
+ try {
+ JSON.parse(slice)
+ return slice
+ } catch {
+ // Try an earlier opener, then an earlier closer.
+ }
+ }
+ }
+ return undefined
+}
+
+export function appendOutputSchemaInstruction(
+ prompt: string,
+ schema: unknown,
+): string {
+ return `${prompt}
+
+Respond with a single JSON object that matches this JSON Schema. Do not wrap the object in markdown unless you must.
+
+${JSON.stringify(schema)}`
+}
diff --git a/packages/ai/tests/chat-combined-event-structured-output.test.ts b/packages/ai/tests/chat-combined-event-structured-output.test.ts
new file mode 100644
index 0000000000..f33e0e0d1e
--- /dev/null
+++ b/packages/ai/tests/chat-combined-event-structured-output.test.ts
@@ -0,0 +1,273 @@
+/**
+ * Native combined mode with `combinedStructuredOutputSource() === 'event'`.
+ *
+ * Harness adapters emit `structured-output.complete` during chatStream.
+ * The engine must harvest that event and must not JSON.parse assistant prose.
+ */
+import { describe, expect, it } from 'vitest'
+import { z } from 'zod'
+import { chat } from '../src/activities/chat/index'
+import { EventType } from '../src/types'
+import { collectChunks, createMockAdapter } from './test-utils'
+import type { StreamChunk } from '../src/types'
+
+const PersonSchema = z.object({
+ name: z.string(),
+ age: z.number(),
+})
+type Person = z.infer
+
+const validPerson: Person = { name: 'Jane Roe', age: 31 }
+
+function isNamedCustom(chunk: StreamChunk, name: string): boolean {
+ return chunk.type === EventType.CUSTOM && chunk.name === name
+}
+
+function eventSourcedTurn(args: {
+ prose: string
+ complete?: Person
+ runError?: string
+}): Array {
+ const ts = 1
+ const chunks: Array = [
+ {
+ type: EventType.RUN_STARTED,
+ runId: 'run-1',
+ threadId: 'thread-1',
+ timestamp: ts,
+ },
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'msg-1',
+ role: 'assistant',
+ timestamp: ts,
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'msg-1',
+ delta: args.prose,
+ timestamp: ts,
+ },
+ {
+ type: EventType.TEXT_MESSAGE_END,
+ messageId: 'msg-1',
+ timestamp: ts,
+ },
+ ]
+ if (args.complete) {
+ const raw = JSON.stringify(args.complete)
+ chunks.push(
+ {
+ type: EventType.CUSTOM,
+ name: 'structured-output.start',
+ value: { messageId: 'msg-so' },
+ timestamp: ts,
+ },
+ {
+ type: EventType.CUSTOM,
+ name: 'structured-output.complete',
+ value: { object: args.complete, raw, messageId: 'msg-so' },
+ timestamp: ts,
+ },
+ )
+ }
+ if (args.runError) {
+ chunks.push({
+ type: EventType.RUN_ERROR,
+ runId: 'run-1',
+ threadId: 'thread-1',
+ timestamp: ts,
+ message: args.runError,
+ error: { message: args.runError },
+ })
+ } else {
+ chunks.push({
+ type: EventType.RUN_FINISHED,
+ runId: 'run-1',
+ threadId: 'thread-1',
+ finishReason: 'stop',
+ timestamp: ts,
+ })
+ }
+ return chunks
+}
+
+describe('chat({ outputSchema }) — combined event source', () => {
+ it('forwards outputSchema to chatStream and skips the finalization adapter call', async () => {
+ let structuredCalled = false
+ let structuredStreamCalled = false
+
+ const { adapter, calls } = createMockAdapter({
+ iterations: [
+ eventSourcedTurn({ prose: 'looking around', complete: validPerson }),
+ ],
+ structuredOutput: async () => {
+ structuredCalled = true
+ return { data: {}, rawText: '{}' }
+ },
+ structuredOutputStream: () => {
+ structuredStreamCalled = true
+ return (async function* () {})()
+ },
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ stream: true,
+ }),
+ )
+
+ expect(calls.length).toBe(1)
+ expect(calls[0]?.outputSchema).toBeDefined()
+ expect(structuredCalled).toBe(false)
+ expect(structuredStreamCalled).toBe(false)
+ })
+
+ it('returns the adapter complete object on the Promise path', async () => {
+ const { adapter } = createMockAdapter({
+ iterations: [
+ eventSourcedTurn({
+ prose: 'I will look around.',
+ complete: validPerson,
+ }),
+ ],
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ const person = await chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ })
+
+ expect(person).toEqual(validPerson)
+ })
+
+ it('does not synthesize start before prose text on the stream path', async () => {
+ const { adapter } = createMockAdapter({
+ iterations: [
+ eventSourcedTurn({
+ prose: 'I will look around.',
+ complete: validPerson,
+ }),
+ ],
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ stream: true,
+ }),
+ )
+
+ const startIdx = chunks.findIndex((c) =>
+ isNamedCustom(c, 'structured-output.start'),
+ )
+ const textStartIdx = chunks.findIndex(
+ (c) => c.type === EventType.TEXT_MESSAGE_START,
+ )
+ const completeIdx = chunks.findIndex((c) =>
+ isNamedCustom(c, 'structured-output.complete'),
+ )
+
+ expect(textStartIdx).toBeGreaterThanOrEqual(0)
+ expect(startIdx).toBeGreaterThan(textStartIdx)
+ expect(completeIdx).toBeGreaterThan(startIdx)
+
+ const completes = chunks.filter((c) =>
+ isNamedCustom(c, 'structured-output.complete'),
+ )
+ expect(completes).toHaveLength(1)
+ const complete = completes[0]
+ expect(complete?.type).toBe(EventType.CUSTOM)
+ if (complete?.type === EventType.CUSTOM) {
+ expect(complete.value).toEqual(
+ expect.objectContaining({ object: validPerson }),
+ )
+ }
+ })
+
+ it('errors with missing-result when no complete event arrives', async () => {
+ const { adapter } = createMockAdapter({
+ iterations: [eventSourcedTurn({ prose: 'I will look around.' })],
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ stream: true,
+ }),
+ )
+
+ const runError = chunks.find((c) => c.type === EventType.RUN_ERROR)
+ expect(runError?.type).toBe(EventType.RUN_ERROR)
+ if (runError?.type === EventType.RUN_ERROR) {
+ expect(runError.code).toBe('structured-output-missing-result')
+ }
+ })
+
+ it('does not harvest a second error after an adapter RUN_ERROR', async () => {
+ const { adapter } = createMockAdapter({
+ iterations: [
+ eventSourcedTurn({
+ prose: 'I will look around.',
+ runError: 'harness failed',
+ }),
+ ],
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ const chunks = await collectChunks(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ stream: true,
+ }),
+ )
+
+ const errors = chunks.filter((c) => c.type === EventType.RUN_ERROR)
+ expect(errors).toHaveLength(1)
+ if (errors[0]?.type === EventType.RUN_ERROR) {
+ expect(errors[0].message).toBe('harness failed')
+ expect(errors[0].code).not.toBe('structured-output-missing-result')
+ expect(errors[0].code).not.toBe('structured-output-parse-failed')
+ }
+ })
+
+ it('throws the adapter RUN_ERROR on the Promise path', async () => {
+ const { adapter } = createMockAdapter({
+ iterations: [
+ eventSourcedTurn({
+ prose: 'I will look around.',
+ runError: 'harness failed',
+ }),
+ ],
+ supportsCombinedToolsAndSchema: true,
+ combinedStructuredOutputSource: 'event',
+ })
+
+ await expect(
+ chat({
+ adapter,
+ messages: [{ role: 'user', content: 'extract' }],
+ outputSchema: PersonSchema,
+ }),
+ ).rejects.toThrow('harness failed')
+ })
+})
diff --git a/packages/ai/tests/stream-processor.test.ts b/packages/ai/tests/stream-processor.test.ts
index 72b825771a..714ea4afc9 100644
--- a/packages/ai/tests/stream-processor.test.ts
+++ b/packages/ai/tests/stream-processor.test.ts
@@ -4428,6 +4428,51 @@ describe('StreamProcessor', () => {
expect((sop as any).raw).toBe('{"name":"Alice"}')
})
+ it('attaches a late structured-output.complete to the open assistant when the event uses a new messageId', () => {
+ const processor = new StreamProcessor()
+ const report = {
+ name: 'TanStack AI',
+ oneLiner: 'Type-safe AI SDK',
+ }
+
+ processor.processChunk(ev.runStarted())
+ processor.processChunk(ev.toolStart('toolu_1', 'Read'))
+ processor.processChunk(ev.toolEnd('toolu_1', 'Read'))
+ processor.processChunk(ev.textStart('msg-1'))
+ processor.processChunk(
+ ev.textContent('I have enough to produce the report.', 'msg-1'),
+ )
+ processor.processChunk(ev.textEnd('msg-1'))
+ processor.processChunk(
+ chunk(EventType.CUSTOM, {
+ name: 'structured-output.start',
+ value: { messageId: 'so-fresh-id' },
+ }),
+ )
+ processor.processChunk(
+ chunk(EventType.CUSTOM, {
+ name: 'structured-output.complete',
+ value: {
+ object: report,
+ raw: JSON.stringify(report),
+ messageId: 'so-fresh-id',
+ },
+ }),
+ )
+ processor.processChunk(ev.runFinished('stop'))
+
+ const assistants = processor
+ .getMessages()
+ .filter((m) => m.role === 'assistant')
+ expect(assistants).toHaveLength(1)
+ const sop = assistants[0]!.parts.find(
+ (p) => p.type === 'structured-output',
+ )
+ expect(sop).toBeDefined()
+ expect((sop as { status: string }).status).toBe('complete')
+ expect((sop as { data: unknown }).data).toEqual(report)
+ })
+
it('progressively populates partial as JSON deltas arrive', () => {
const processor = new StreamProcessor()
diff --git a/packages/ai/tests/structured-output-text.test.ts b/packages/ai/tests/structured-output-text.test.ts
new file mode 100644
index 0000000000..d87d562ffd
--- /dev/null
+++ b/packages/ai/tests/structured-output-text.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest'
+import {
+ appendOutputSchemaInstruction,
+ parseJsonFromAssistantText,
+} from '../src/utilities/structured-output-text'
+
+describe('parseJsonFromAssistantText', () => {
+ it('parses a bare object', () => {
+ expect(parseJsonFromAssistantText('{"a":1}')).toEqual({ a: 1 })
+ })
+
+ it('strips a json fence', () => {
+ expect(parseJsonFromAssistantText('```json\n{"a":1}\n```')).toEqual({
+ a: 1,
+ })
+ })
+
+ it('strips a bare fence', () => {
+ expect(parseJsonFromAssistantText('```\n{"a":1}\n```')).toEqual({ a: 1 })
+ })
+
+ it('throws on prose', () => {
+ expect(() => parseJsonFromAssistantText('not json')).toThrow()
+ })
+
+ it('takes the last object after assistant prose', () => {
+ expect(
+ parseJsonFromAssistantText('I\'ll read the README.\n{"a":1}'),
+ ).toEqual({ a: 1 })
+ })
+
+ it('takes the last fenced object after prose', () => {
+ expect(
+ parseJsonFromAssistantText('Looking around.\n```json\n{"a":2}\n```'),
+ ).toEqual({ a: 2 })
+ })
+
+ it('ignores a brace in prose after a valid object', () => {
+ expect(parseJsonFromAssistantText('{"a":1}\nTool note: }')).toEqual({
+ a: 1,
+ })
+ })
+
+ it('appends the schema instruction', () => {
+ const next = appendOutputSchemaInstruction('Look around.', {
+ type: 'object',
+ })
+ expect(next).toContain('Look around.')
+ expect(next).toContain('"type":"object"')
+ })
+})
diff --git a/packages/ai/tests/test-utils.ts b/packages/ai/tests/test-utils.ts
index 2c6b713037..ffd0c28036 100644
--- a/packages/ai/tests/test-utils.ts
+++ b/packages/ai/tests/test-utils.ts
@@ -162,6 +162,9 @@ export function createMockAdapter(options: {
* The engine then forwards `outputSchema` into `chatStream` and skips
* the separate finalization round-trip. */
supportsCombinedToolsAndSchema?: boolean
+ /** When `'event'`, the engine harvests `structured-output.complete`
+ * from `chatStream` instead of parsing accumulated assistant text. */
+ combinedStructuredOutputSource?: 'text' | 'event'
}) {
const calls: Array> = []
let callIndex = 0
@@ -213,6 +216,10 @@ export function createMockAdapter(options: {
adapter.supportsCombinedToolsAndSchema = () => true
}
+ if (options.combinedStructuredOutputSource === 'event') {
+ adapter.combinedStructuredOutputSource = () => 'event'
+ }
+
return { adapter, calls }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e77cd9c60..92ff2c6bfe 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -676,6 +676,9 @@ importers:
'@tanstack/ai':
specifier: workspace:*
version: link:../../packages/ai
+ '@tanstack/ai-acp':
+ specifier: workspace:*
+ version: link:../../packages/ai-acp
'@tanstack/ai-anthropic':
specifier: workspace:*
version: link:../../packages/ai-anthropic
@@ -3884,10 +3887,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/runtime@7.28.4':
- resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
- engines: {node: '>=6.9.0'}
-
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
@@ -17531,7 +17530,7 @@ snapshots:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
'@babel/helper-compilation-targets': 7.29.7
- '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0)
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0(supports-color@7.2.0))
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
@@ -17769,7 +17768,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0)':
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0(supports-color@7.2.0))':
dependencies:
'@babel/core': 7.29.0(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7
@@ -18342,8 +18341,6 @@ snapshots:
pirates: 4.0.7
source-map-support: 0.5.21
- '@babel/runtime@7.28.4': {}
-
'@babel/runtime@7.29.2': {}
'@babel/template@7.27.2':
@@ -18429,7 +18426,7 @@ snapshots:
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
- semver: 7.7.4
+ semver: 7.8.4
'@changesets/assemble-release-plan@6.0.9(patch_hash=1bc53c741da20baad9cdeb674c599225dcf9fe6aa7b0de16ff87e63f12a97b24)':
dependencies:
@@ -18438,7 +18435,7 @@ snapshots:
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
- semver: 7.7.4
+ semver: 7.8.4
'@changesets/changelog-git@0.2.1':
dependencies:
@@ -18503,7 +18500,7 @@ snapshots:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
picocolors: 1.1.1
- semver: 7.7.4
+ semver: 7.8.4
'@changesets/get-github-info@0.8.0':
dependencies:
@@ -19965,7 +19962,7 @@ snapshots:
'@manypkg/get-packages@1.1.3':
dependencies:
- '@babel/runtime': 7.28.4
+ '@babel/runtime': 7.29.2
'@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0
fs-extra: 8.1.0
@@ -29718,7 +29715,7 @@ snapshots:
less: 4.6.6
ora: 9.4.0
piscina: 5.2.0
- postcss: 8.5.19
+ postcss: 8.5.26
rollup-plugin-dts: 6.4.1(rollup@4.60.1)(typescript@5.9.3)
rxjs: 7.8.2
sass: 1.101.0