構造化出力(structured outputs)を使い始めると、返ってくる値に型が付いて安心します。ですが本番で長く動かすと、型では防げない失敗に必ず出会います。subtype が success なのに肝心の値が空だった、モデルフォールバックで一度完成した出力が途中で取り消された、Zod で書いたスキーマが起動時に丸ごと拒否された——どれも「型を付けたのに落ちる」経路です。
この記事は、Claude Agent SDK(TypeScript)で構造化出力をゼロから1本通し、検証失敗を「例外的な事故」ではなく「設計対象」として扱うための実装をまとめます。2026-10-01 時点で公式ドキュメントに記載のある挙動を出典付きで押さえたうえで、docs に載っていない「自分ならこう作る」という運用の線引きまで書きます。
検証はスキーマとの照合で動く
SDK の構造化出力は、query() に渡した outputFormat のスキーマに対して出力を照合し、合わなければ再プロンプトを行います。
the SDK validates the output against it, re-prompting on mismatch
つまり「型が付く」の実体は、モデル出力をスキーマで検証し、ズレたらもう一度モデルに書き直させるループです。リトライ上限内で成功しなければ、構造化データではなくエラーが返ります。ここが本番設計の出発点で、成功は保証されておらず、失敗は正常系の一部です。
スキーマ側にも制約があります。SDK は JSON Schema draft-07 でスキーマを検証します。
The SDK validates schemas with JSON Schema draft-07
新しいドラフトを宣言するスキーマは拒否されます。Zod のデフォルト出力は draft 2020-12 なので、変換時に draft-7 を指定する必要があります。有効な JSON Schema でないスキーマは、問題点を名指しするエラーで起動時に実行を止めます(fails the run at startup with an error naming the problem)。これは比較的新しい挙動で、v2.1.205 より前は無効なスキーマは黙って無視され、エージェントは非構造化テキストを返していました(an invalid schema was silently ignored and the agent returned unstructured text)。黙って壊れる代わりに、起動時に大きな声で止まるようになった、という変化です。
ゼロから作る通し実装
空のディレクトリから始めます。前提は Node.js 20 以上、TypeScript 5.6 系、Zod 4 系、Claude Agent SDK は v2.1.205 以降(本記事の挙動はこのバージョン以降が前提)。
mkdir structured-output-demo && cd structured-output-demo
npm init -y
npm install @anthropic-ai/claude-agent-sdk zod
npm install -D tsx typescript
package.json に ESM 指定を追加します(tsx で実行するため)。
{
"name": "structured-output-demo",
"private": true,
"type": "module"
}
スキーマは Zod を単一の情報源にし、draft-7 に変換して SDK へ渡します。schema.ts:
import { z } from "zod";
export const TicketSchema = z.object({
summary: z.string().min(1),
severity: z.enum(["low", "medium", "high", "critical"]),
component: z.string(),
needs_human: z.boolean(),
});
export type Ticket = z.infer<typeof TicketSchema>;
// Zod のデフォルトは draft 2020-12。draft-7 を明示しないと SDK に拒否される。
export const ticketJsonSchema = z.toJSONSchema(TicketSchema, {
target: "draft-7",
});
本体 main.ts。結果メッセージだけを拾い、成否をひとつの Resolved 型に畳みます。
import { query } from "@anthropic-ai/claude-agent-sdk";
import { TicketSchema, ticketJsonSchema, type Ticket } from "./schema.js";
type Resolved =
| { ok: true; value: Ticket }
| { ok: false; reason: string; retryable: boolean };
// 実際の result メッセージ型は SDK の型定義に従う。ここでは参照するフィールドだけ記す。
type ResultLike = {
type: string;
subtype: string;
structured_output?: unknown;
errors?: unknown[];
};
function resolveResult(result: ResultLike): Resolved {
// (1) success 以外の subtype(例: error_max_structured_output_retries)
if (result.subtype !== "success") {
// errors を必ず丸ごと残す。ここに「検証失敗」か「フォールバック取り消し」かが出る。
console.error("structured output failed:", JSON.stringify(result.errors, null, 2));
return { ok: false, reason: `subtype=${result.subtype}`, retryable: true };
}
// (2) success だが structured_output が無い → 失敗として扱う
if (result.structured_output === undefined) {
return { ok: false, reason: "success with no structured_output", retryable: true };
}
// (3) 値はある。SDK は format を強制しないので、自分側で Zod により再検証する。
const parsed = TicketSchema.safeParse(result.structured_output);
if (!parsed.success) {
return { ok: false, reason: parsed.error.message, retryable: false };
}
return { ok: true, value: parsed.data };
}
async function extractTicket(report: string): Promise<Resolved> {
const response = query({
prompt: `Turn this bug report into a triage ticket.\n\n${report}`,
options: {
outputFormat: { type: "json_schema", schema: ticketJsonSchema },
},
});
for await (const message of response) {
if ((message as ResultLike).type === "result") {
return resolveResult(message as ResultLike);
}
}
return { ok: false, reason: "stream ended without a result", retryable: true };
}
const report =
"Login page returns 500 for SSO users since the 3.4 deploy. " +
"Started 20 minutes ago, affects all tenants.";
const result = await extractTicket(report);
if (result.ok) {
console.log("ticket:", result.value);
process.exit(0);
} else {
console.error("failed:", result.reason, "retryable:", result.retryable);
process.exit(1);
}
実行:
export ANTHROPIC_API_KEY=sk-ant-...
npx tsx main.ts
成功時は、スキーマに一致するオブジェクトが表示され、プロセスは終了コード 0 で終わります。フィールドの値はモデルが決めるため一字一句は固定されませんが、形は次のようになります(成功判定は「スキーマに合致するオブジェクトが出て、exit 0」)。
ticket: {
summary: 'SSO users get HTTP 500 on login since the 3.4 deploy',
severity: 'critical',
component: 'auth/sso',
needs_human: true
}
schema.ts の target: "draft-7" を外すと、Zod は draft 2020-12 を宣言し、SDK は起動時にそれを拒否します。Claude Code / CLI は、こうした構造的な不一致を「問題を名指しして起動時に止める」形で報告します。次は構造化出力そのものではなく設定ファイル(settings.json)の例ですが、起動時スキーマ検証エラーの見え方が同じであることを示す実機出力です。
Settings Error
<your-project>/.claude/settings.json
└ permissions.allow: Expected array, but received string
Permission rules must be in an array. Format: ["Tool(specifier)"]. Examples:
["Bash(npm run build)", "Edit(docs/**)", "Read(~/.zshrc)"]. Use * for wildcards.
Learn more: https://code.claude.com/docs/en/iam#configuring-permissions
Files with errors are skipped entirely, not just the invalid settings.
❯ 1. Fix with Claude
2. Exit and fix manually
3. Continue without these settings
Enter to confirm · Esc to cancel
構造化出力のスキーマも同じ思想で、「無効なら黙って進まず、起動時に名指しで止める」。この挙動があるおかげで、draft の取り違えは実行前に気づけます。
結果を信頼しない:3つの失敗を畳む
subtype が success だからといって安心はできません。
A result can also end with subtype success but no structured_output value
success でも structured_output が存在しないことがあり、その場合も失敗として扱うべきだと明記されています(Treat that case as a failure as well)。上の resolveResult はこれを踏まえ、(1) success 以外の subtype、(2) success だが値なし、(3) 値はあるがローカル検証に落ちる、の3経路をすべて ok: false に落とします。ビジネスロジックが触れるのは ok: true の値だけになり、「型が付いているのに undefined を掴む」事故が消えます。
検証失敗の2系統を切り分ける
error_max_structured_output_retries は、複数回の試行後に有効な出力が残らなかったことを意味します(No valid output remained after multiple attempts)。ただし原因は2つあり、片方は検証失敗、もう片方はモデルフォールバックによる取り消しです。後者では、一度完成した出力がストリーム途中で取り消され(a model fallback can retract an already-completed output mid-stream)、リトライで置き換えられなければ同じエラーで終わります。この2つは subtype だけでは見分けられません。
Check the errors list on the result message to tell the two causes apart
だから resolveResult では、subtype が success でない時に errors を必ず丸ごとログへ出しています。検証失敗ならスキーマ側を疑い、フォールバック取り消しなら一過性寄りとして再試行を検討する——この分岐は errors を見て初めて根拠を持てます。
自分ならこう作る(設計判断)
ここからは事実ではなく設計上の意見です。第一に、構造化出力は「型保証」ではなく「失敗しうるネットワーク呼び出し」として扱います。上のように成否を単一の Resolved に畳み、呼び出し側は ok だけを見る。第二に、再試行の線引きを subtype ではなく errors で決めます。フォールバック取り消しは再試行の価値がありますが、スキーマ不一致の連続はコードの問題なので、機械的に再試行するとトークンを浪費するだけです。第三に、SDK の検証を最終防衛線にしないことです。
is accepted as an annotation and isn't enforced by the SDK's validator
format(例: "format": "email")は注釈として受理されるだけで、SDK のバリデーターは強制しません。メール形式やレンジのような意味的制約が必要なら、受け取った structured_output を自分側で Zod の safeParse に通し、二重に検証します。SDK は「形が JSON Schema に合うか」を見ますが、「値が業務的に妥当か」は見ない、という役割分担で考えると設計がぶれません。
既知の限界
format が強制されない点は前述の通りで、v2.1.205 より前は format を含むスキーマは無効として扱われていたため、バージョン差でスキーマの通り方が変わります。TypeScript で query() を single-shot(1回きり)として消費する場合は、エラー結果を yield した後に例外を throw します(A single-shot query() throws after yielding an error result)。したがって single-shot 経路は必ず try/catch で囲み、ストリーム経路(上の for-await)とはエラーの受け取り方が異なる点に注意してください。
References
[1] https://code.claude.com/docs/en/agent-sdk/structured-outputs