Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
420dd13
feat: honor outputSchema on dedicated harness adapters
AlemTuzlak Aug 14, 2026
9b78980
fix(docs): give adapter outputSchema snippets a real sandbox
AlemTuzlak Aug 14, 2026
1a909c1
fix(example): render repo report on its own route
AlemTuzlak Aug 17, 2026
17bd21e
fix: parse harness JSON after prose and render the report card
AlemTuzlak Aug 17, 2026
1103b2b
fix: pass Claude --json-schema inline and stop guessing the report card
AlemTuzlak Aug 17, 2026
7f0c3ed
fix(ai-claude-code): expand --json-schema from a file and skip projec…
AlemTuzlak Aug 17, 2026
1a87e8b
fix(ai-claude-code): spawn Claude with real argv for --json-schema
AlemTuzlak Aug 17, 2026
25a8065
fix(ai-claude-code): stop sending --json-schema through the shell
AlemTuzlak Aug 17, 2026
3320f4b
fix(ai-claude-code): skip project settings with --setting-sources
AlemTuzlak Aug 17, 2026
d4595b9
fix(ai-claude-code): pass host Anthropic auth into the sandbox
AlemTuzlak Aug 17, 2026
e533f7c
fix(ai-claude-code): do not use --bare (it ignores claude login)
AlemTuzlak Aug 17, 2026
f7dff32
fix(ai-claude-code): harvest StructuredOutput tool into chat.final
AlemTuzlak Aug 17, 2026
e9b62ae
fix(ai-claude-code): harvest streamed StructuredOutput JSON
AlemTuzlak Aug 17, 2026
b90c885
fix(ai): attach late structured-output.complete to the open assistant
AlemTuzlak Aug 17, 2026
9fc761d
Merge remote-tracking branch 'origin/main' into feat/harness-output-s…
AlemTuzlak Aug 17, 2026
46fc9b8
fix: address review comments and sync lockfile with main
AlemTuzlak Aug 17, 2026
9d3e19e
Merge branch 'main' into feat/harness-output-schema
AlemTuzlak Aug 18, 2026
7677814
Merge remote-tracking branch 'origin/main' into feat/harness-output-s…
AlemTuzlak Aug 18, 2026
968b823
feat(ai-acp): honor outputSchema and add ACP repo-report example
AlemTuzlak Aug 18, 2026
94d6cf9
ci: apply automated fixes
autofix-ci[bot] Aug 18, 2026
5384cec
fix: emit harness structured output before RUN_FINISHED
AlemTuzlak Aug 18, 2026
dc92df3
fix(ai-codex): stream intermediate prose and parse last schema JSON
AlemTuzlak Aug 18, 2026
b516a4b
fix: make harness auth a setting and stream Codex text
AlemTuzlak Aug 18, 2026
09ee0d0
docs: document harness authMode (host vs api-key)
AlemTuzlak Aug 18, 2026
b8154da
fix: default harness authMode to api-key
AlemTuzlak Aug 18, 2026
2ccef93
Merge branch 'main' into feat/harness-output-schema
AlemTuzlak Aug 18, 2026
7657b04
fix: make grok ACP tests and auth docs pass CI
AlemTuzlak Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/harness-output-schema.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 102 additions & 2 deletions docs/adapters/acp-compatible.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <p key={part.id}>{part.name}</p>
}
if (part.type === 'structured-output') {
const report = part.data ?? part.partial
return report?.name ? <h2 key={index}>{report.name}</h2> : null
}
return null
}),
)}
{final ? <p>{final.oneLiner}</p> : 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
Expand Down Expand Up @@ -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

Expand All @@ -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`. |
Expand Down
76 changes: 72 additions & 4 deletions docs/adapters/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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 <p>The agent is inspecting the repo.</p>
if (!final) return null
return <p>{final.summary}</p>
}
```

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

Expand Down
76 changes: 71 additions & 5 deletions docs/adapters/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 <p>The agent is inspecting the repo.</p>
if (!final) return null
return <p>{final.summary}</p>
}
```

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

Expand Down
Loading
Loading