Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
8 changes: 8 additions & 0 deletions .changeset/harness-structured-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/ai-codex': minor
'@tanstack/ai-claude-code': minor
'@tanstack/ai-opencode': minor
'@tanstack/ai-grok-build': patch
---

feat: honor `outputSchema` in the CLI harness adapters that support it natively. `chat({ outputSchema })` now works with the Codex (`codex exec --output-schema`), Claude Code (`claude -p --json-schema`), and OpenCode (`json_schema` output format) harnesses — the schema-constrained answer is produced within the single harness run and harvested by the engine (via `supportsCombinedToolsAndSchema`), with no separate finalization round-trip. The Codex harness throws when `tools` and `outputSchema` are combined, because Codex silently drops the schema when MCP/tools are active (openai/codex#15451). The Grok Build harness now rejects `outputSchema` with an accurate message (the grok CLI has no schema mechanism).
20 changes: 19 additions & 1 deletion docs/adapters/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,25 @@ 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` to `chat()` and the harness constrains its final answer to your schema in the **same** run — the adapter forwards the JSON Schema to `claude -p --json-schema`, and the schema-conforming result (returned in Claude Code's `structured_output`) is harvested by the engine. This works alongside the harness's own tools.

```typescript
import { chat } from "@tanstack/ai";
import { claudeCodeText } from "@tanstack/ai-claude-code";
import { z } from "zod";

const result = await chat({
adapter: claudeCodeText("claude-sonnet-4-6"),
messages: [{ role: "user", content: "Audit deps and list the outdated ones." }],
outputSchema: z.object({
outdated: z.array(z.object({ name: z.string(), latest: z.string() })),
}),
});

result.outdated; // { name: string; latest: string }[] — typed and validated
```

For plain structured extraction that isn't a coding task, a provider adapter (e.g. `@tanstack/ai-anthropic`) is faster and doesn't spawn a subprocess.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Limitations

Expand Down
21 changes: 20 additions & 1 deletion docs/adapters/codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,26 @@ 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` to `chat()` and the harness constrains its final answer to your schema in the **same** run — the adapter forwards the JSON Schema to `codex exec --output-schema`, and the engine harvests the schema-conforming final message. No second call, no separate thread.

```typescript
import { chat } from "@tanstack/ai";
import { codexText } from "@tanstack/ai-codex";
import { z } from "zod";

const result = await chat({
adapter: codexText("gpt-5.1-codex", { sandboxMode: "workspace-write" }),
messages: [{ role: "user", content: "Summarize the failing tests." }],
outputSchema: z.object({
failing: z.array(z.string()),
summary: z.string(),
}),
});

result.failing; // string[] — typed and validated
```

> **Cannot be combined with `tools`.** Codex silently drops the output schema whenever MCP servers / tools are active ([openai/codex#15451](https://github.com/openai/codex/issues/15451)), so the adapter throws if you pass both `tools` and `outputSchema`. Remove one. For plain structured extraction that isn't a coding task, a provider adapter (e.g. `@tanstack/ai-openai`) is faster and doesn't spawn a subprocess.

## Limitations

Expand Down
20 changes: 19 additions & 1 deletion docs/adapters/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,25 @@ 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` to `chat()` and the harness constrains its final answer to your schema in the **same** run — the adapter sends OpenCode's `json_schema` output format on the session prompt, and the schema-conforming result (returned on the message's `structured` field) is harvested by the engine. This works alongside the harness's own tools.

```typescript
import { chat } from "@tanstack/ai";
import { opencodeText } from "@tanstack/ai-opencode";
import { z } from "zod";

const result = await chat({
adapter: opencodeText("anthropic/claude-sonnet-4-5"),
messages: [{ role: "user", content: "List the TODO comments in src/." }],
outputSchema: z.object({
todos: z.array(z.object({ file: z.string(), text: z.string() })),
}),
});

result.todos; // { file: string; text: string }[] — typed and validated
```

Requires an `@opencode-ai/sdk` new enough to expose the `json_schema` output format (v1.17+). For plain structured extraction that isn't a coding task, a provider adapter (e.g. `@tanstack/ai-openai`) is faster and doesn't spawn a harness.

## Limitations

Expand Down
8 changes: 4 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@
"label": "Overview",
"to": "structured-outputs/overview",
"addedAt": "2026-05-19",
"updatedAt": "2026-06-10"
"updatedAt": "2026-07-15"
},
{
"label": "One-Shot Extraction",
Expand Down Expand Up @@ -592,19 +592,19 @@
"label": "Claude Code",
"to": "adapters/claude-code",
"addedAt": "2026-06-12",
"updatedAt": "2026-06-30"
"updatedAt": "2026-07-15"
},
{
"label": "Codex",
"to": "adapters/codex",
"addedAt": "2026-06-12",
"updatedAt": "2026-06-30"
"updatedAt": "2026-07-15"
},
{
"label": "OpenCode",
"to": "adapters/opencode",
"addedAt": "2026-06-12",
"updatedAt": "2026-06-30"
"updatedAt": "2026-07-15"
},
{
"label": "Grok Build",
Expand Down
3 changes: 3 additions & 0 deletions docs/structured-outputs/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,12 @@ 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 / OpenCode (CLI harnesses) | Native CLI schema flag, harvested from the single harness run |

The provider-specific details are handled for you — the same `chat({ outputSchema })` call works across all of them.

The CLI harness adapters constrain their final answer to the schema **within the same harness run** (Claude Code via `--json-schema`, Codex via `--output-schema`, OpenCode via its `json_schema` output format) — no extra round-trip. Two caveats: the Codex harness cannot combine `tools` with `outputSchema` (it silently drops the schema when tools/MCP are active, so the adapter throws), and the Grok Build and ACP-based harnesses expose no schema mechanism at all, so they reject `outputSchema`. See each [adapter page](../adapters/codex.md) for details.

### Anthropic schema complexity limits

Anthropic compiles a structured-output schema into a grammar and rejects schemas it considers too large or too complex with a 400 error — typically `Schema is too complex for compilation` or `output_config.format.schema: Invalid schema: The compiled grammar is too large`. This affects Claude models directly and `anthropic/*` models routed through OpenRouter, even when every other provider accepts the same schema.
Expand Down
31 changes: 29 additions & 2 deletions packages/ai-claude-code/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ export class ClaudeCodeTextAdapter<
if (config.streamPartials !== false) args.push('--include-partial-messages')
if (resume !== undefined) args.push('--resume', q(resume))

// Structured output: constrain the final answer to the JSON Schema. Claude
// Code takes the schema inline; the schema-conformant result comes back in
// the final `result` message's `structured_output` field (which the
// stream-json result event carries), harvested by the engine.
if (options.outputSchema !== undefined) {
args.push('--json-schema', q(JSON.stringify(options.outputSchema)))
}

// Precedence: per-call modelOptions > adapter config > policy > sandbox default.
const permissionMode =
modelOptions?.permissionMode ??
Expand Down Expand Up @@ -437,6 +445,9 @@ export class ClaudeCodeTextAdapter<
...(options.parentRunId !== undefined && {
parentRunId: options.parentRunId,
}),
...(options.outputSchema !== undefined && {
structuredOutput: true,
}),
genId: () => this.generateId(),
onSdkMessage: (message) =>
logger.provider(`provider=claude-code type=${message.type}`, {
Expand Down Expand Up @@ -506,13 +517,29 @@ export class ClaudeCodeTextAdapter<
}
}

/**
* Claude Code constrains its final answer to a JSON Schema via `claude -p
* --json-schema` in the single harness run; the schema-conformant JSON comes
* back in the result message's `structured_output` field. `outputSchema` is
* wired straight into `chatStream` and the engine harvests it — no separate
* finalization round-trip.
*/
supportsCombinedToolsAndSchema(): boolean {
return true
}

/**
* Unreachable via `chat()` — `supportsCombinedToolsAndSchema()` routes every
* structured-output request through `chatStream`. Kept as a safety net for
* any direct caller of the non-combined path.
*/
structuredOutput(
_options: StructuredOutputOptions<ClaudeCodeTextProviderOptions>,
): Promise<StructuredOutputResult<unknown>> {
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.',
'Claude Code structured output runs through chatStream (combined tools+schema mode); ' +
'structuredOutput() should not be called directly.',
),
)
}
Expand Down
51 changes: 51 additions & 0 deletions packages/ai-claude-code/src/stream/translate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export interface TranslateContext {
onSessionId?: (sessionId: string) => void
/** Called for each raw SDK message, for logging. */
onSdkMessage?: (message: AgentSdkMessage) => void
/**
* Structured-output mode (`chat({ outputSchema })`). When set, Claude Code
* was run with `--json-schema`, so the schema-constrained answer arrives in
* the final `result` message's `structured_output` field — NOT as assistant
* text. The engine harvests the structured result by `JSON.parse`-ing the
* run's accumulated text, so we suppress all assistant/partial text (which
* would be natural-language prose) and instead emit exactly ONE terminal
* text message = `JSON.stringify(structured_output)`.
*/
structuredOutput?: boolean
}

/**
Expand Down Expand Up @@ -224,6 +234,10 @@ export async function* translateSdkStream(
for (const block of message.message.content) {
if (block.type === 'text') {
if (alreadyStreamed) continue
// Structured mode: assistant text is natural-language prose; the JSON
// answer comes from the result's `structured_output`. Suppress prose so
// the harvested text is exactly the structured JSON.
if (ctx.structuredOutput === true) continue
const messageId = message.message.id ?? genId()
const text = (block as { text: string }).text
yield {
Expand Down Expand Up @@ -319,6 +333,38 @@ export async function* translateSdkStream(
yield* closePartialReasoning()
yield* synthesizeUnresolvedResults()

// Structured mode: emit the schema-constrained JSON as the single terminal
// text message so the engine can harvest it (prose was suppressed above).
if (
ctx.structuredOutput === true &&
message.subtype === 'success' &&
message.structured_output !== undefined
) {
const messageId = genId()
const text = JSON.stringify(message.structured_output)
yield {
type: EventType.TEXT_MESSAGE_START,
messageId,
model,
timestamp: now(),
role: 'assistant',
}
yield {
type: EventType.TEXT_MESSAGE_CONTENT,
messageId,
model,
timestamp: now(),
delta: text,
content: text,
}
yield {
type: EventType.TEXT_MESSAGE_END,
messageId,
model,
timestamp: now(),
}
}

const usage = buildUsage(message.usage, message.total_cost_usd)
if (message.subtype === 'success') {
yield {
Expand Down Expand Up @@ -365,6 +411,11 @@ export async function* translateSdkStream(
streamedMessageIds.add(partialMessageId)
} else if (event.type === 'content_block_start') {
partialBlockType = event.content_block.type
// Structured mode: suppress streamed prose text (see handleAssistant).
// Reasoning still streams; the JSON answer is emitted from the result.
if (partialBlockType === 'text' && ctx.structuredOutput === true) {
return
}
if (partialBlockType === 'text') {
partialTextMessageId = partialMessageId ?? genId()
partialTextContent = ''
Expand Down
54 changes: 54 additions & 0 deletions packages/ai-claude-code/tests/text-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ const FAKE_CLAUDE = [
`})`,
].join('\n')

// Structured-output stand-in: records argv (so a test can assert --json-schema)
// and emits prose PLUS a result carrying `structured_output`, as `claude -p
// --json-schema --output-format stream-json` would.
const FAKE_CLAUDE_STRUCTURED = [
`import { writeFileSync } from 'node:fs'`,
`writeFileSync('claude-argv.txt', process.argv.join(' '))`,
`process.stdin.on('data', () => {})`,
`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: [] })`,
` w({ type: 'assistant', message: { id: 'msg-1', content: [{ type: 'text', text: 'Let me think...' }] }, parent_tool_use_id: null })`,
` w({ type: 'result', subtype: 'success', result: 'ok', structured_output: { answer: 'pong' }, usage: { input_tokens: 1, output_tokens: 1 } })`,
`})`,
].join('\n')

const noopLogger = {
request: () => {},
provider: () => {},
Expand Down Expand Up @@ -116,6 +131,45 @@ describe('claude-code in-sandbox adapter', () => {
await sbx.destroy()
})

it('passes --json-schema and harvests structured_output as the answer', async () => {
const sbx = await provider.create({})
await sbx.fs.write('/workspace/fake-claude.mjs', FAKE_CLAUDE_STRUCTURED)

const adapter = claudeCodeText('haiku', {
claudeExecutable: 'node fake-claude.mjs',
streamPartials: false,
emitDiff: false,
})
const schema = {
type: 'object',
properties: { answer: { type: 'string' } },
required: ['answer'],
additionalProperties: false,
}
const chunks = await collect(
adapter.chatStream({
model: 'haiku',
messages: [{ role: 'user', content: 'say pong' }],
logger: noopLogger,
capabilities: capabilityContextWith(sbx),
outputSchema: schema,
}),
)

const argv = await sbx.fs.read('/workspace/claude-argv.txt')
expect(argv).toContain('--json-schema')

// Prose ("Let me think...") is suppressed; only the structured JSON is
// harvestable text.
const text = chunks
.filter((c) => c.type === 'TEXT_MESSAGE_CONTENT')
.map((c) => (c as { delta?: string }).delta ?? '')
.join('')
expect(text).toBe('{"answer":"pong"}')
expect(JSON.parse(text)).toEqual({ answer: 'pong' })
await sbx.destroy()
})

it('requires a sandbox capability', async () => {
const adapter = claudeCodeText('haiku', { emitDiff: false })
const chunks = await collect(
Expand Down
42 changes: 42 additions & 0 deletions packages/ai-claude-code/tests/translate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,49 @@ const resultSuccess: AgentSdkMessage = {
total_cost_usd: 0.12,
}

async function collectStructured(
messages: Array<AgentSdkMessage>,
): Promise<Array<StreamChunk>> {
const chunks: Array<StreamChunk> = []
for await (const chunk of translateSdkStream(fromArray(messages), {
...makeContext(),
structuredOutput: true,
})) {
chunks.push(chunk)
}
return chunks
}

describe('translateSdkStream', () => {
it('structured mode suppresses prose and emits structured_output as terminal text', async () => {
const chunks = await collectStructured([
init,
// Natural-language prose the model emits during the run — must be dropped.
assistantText('Let me work on that...'),
{
type: 'result',
subtype: 'success',
result: 'done',
structured_output: { answer: 'pong' },
usage,
total_cost_usd: 0.01,
},
])

// Exactly one text burst, carrying the structured JSON (not the prose).
const textContents = chunks.filter((c) => c.type === 'TEXT_MESSAGE_CONTENT')
expect(textContents).toHaveLength(1)
expect(textContents[0]).toMatchObject({ content: '{"answer":"pong"}' })
expect(
JSON.parse((textContents[0] as { content: string }).content),
).toEqual({ answer: 'pong' })
// Emitted before the terminal RUN_FINISHED so it's harvestable.
const endIdx = chunks.findIndex((c) => c.type === 'TEXT_MESSAGE_END')
const finishedIdx = chunks.findIndex((c) => c.type === 'RUN_FINISHED')
expect(endIdx).toBeGreaterThanOrEqual(0)
expect(endIdx).toBeLessThan(finishedIdx)
})

it('translates a simple text turn into RUN_STARTED → CUSTOM → TEXT_* → RUN_FINISHED(stop)', async () => {
const chunks = await collect([init, assistantText('Hello!'), resultSuccess])

Expand Down
Loading
Loading