Skip to content

Structured outputs

Get validated JSON back from an Agent SDK run using JSON Schema, Zod or Pydantic, even after many turns of tool use.

Free text is fine when a person reads the answer. It is a nuisance when code has to. Structured outputs let you hand the SDK a JSON Schema; the agent can use whatever tools it needs along the way, and at the end you receive an object that has been validated against that schema. If the output does not match, the SDK sends Claude back to fix it. If it still cannot produce a valid object within the retry limit, you get an error result instead of bad data.

Why bother

Picture an agent that checks a supplier's website and reports their current delivery terms. Without a schema you get something like:

Good news: Northfield Packaging currently offers free delivery on orders
over £250, with a standard lead time of around 3 to 5 working days...

To use that you would need to pull out the threshold, turn "3 to 5 working days" into numbers, and cope with a different phrasing next week. With a schema you get:

{
  "supplier": "Northfield Packaging",
  "free_delivery_threshold_gbp": 250,
  "lead_time_days": { "min": 3, "max": 5 },
  "source_url": "https://example.com/delivery"
}

which you can write straight to a database.

Getting started

Pass an outputFormat (TypeScript) or output_format (Python) with type: "json_schema" and your schema. When the run succeeds, the result message has a structured_output field containing the validated data. Install the SDK first using the quickstart.

import { query } from "@anthropic-ai/claude-agent-sdk";

const schema = {
  type: "object",
  properties: {
    package_name: { type: "string" },
    current_version: { type: "string" },
    latest_version: { type: "string" },
    breaking_changes: { type: "boolean" },
  },
  required: ["package_name", "current_version", "latest_version"],
};

try {
  for await (const msg of query({
    prompt: "Check which version of express this project uses and whether upgrading to the latest would be breaking",
    options: {
      allowedTools: ["Read", "Bash", "WebSearch"],
      outputFormat: { type: "json_schema", schema },
    },
  })) {
    if (msg.type === "result" && msg.subtype === "success" && msg.structured_output) {
      console.log(msg.structured_output);
    }
  }
} catch (err) {
  console.error("Run failed:", err);
}
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage

schema = {
    "type": "object",
    "properties": {
        "package_name": {"type": "string"},
        "current_version": {"type": "string"},
        "latest_version": {"type": "string"},
        "breaking_changes": {"type": "boolean"},
    },
    "required": ["package_name", "current_version", "latest_version"],
}

async def check_express():
    opts = ClaudeAgentOptions(
        allowed_tools=["Read", "Bash", "WebSearch"],
        output_format={"type": "json_schema", "schema": schema},
    )
    try:
        async for msg in query(prompt="Check which version of express this project uses...", options=opts):
            if isinstance(msg, ResultMessage) and msg.subtype == "success" and msg.structured_output:
                print(msg.structured_output)
    except Exception as exc:
        print(f"Run failed: {exc}")

The try is there because a single-message query() raises after yielding an error result, including error_max_structured_output_retries.

The outputFormat option

FieldValue
type"json_schema"
schemaA JSON Schema object describing the result

Supported: all the basic types (object, array, string, number, boolean, null), enum, const, required, nested objects and $ref definitions. Some JSON Schema features are not supported; Anthropic's API documentation on structured outputs lists the limitations.

Points to know:

  • Draft-07. Schemas are validated as JSON Schema draft-07, and a schema declaring a newer draft is rejected.
  • Invalid schemas fail fast. A schema that is not valid JSON Schema stops the run at startup with an error naming the problem. Before Claude Code v2.1.205, an invalid schema was silently ignored and you got plain text back.
  • format is advisory. Keywords like "format": "email" are accepted as annotations but not enforced. Before v2.1.205, any schema containing format counted as invalid.

Typed schemas with Zod and Pydantic

Writing JSON Schema by hand gets tedious, and you lose type checking on the result. Define the shape with Zod or Pydantic instead, generate the schema from it, and parse the result back into a typed object.

TypeScript with Zod. Zod emits draft 2020-12 by default, so ask for draft-07 explicitly:

import { z } from "zod";
import { query } from "@anthropic-ai/claude-agent-sdk";

const Finding = z.object({
  file: z.string(),
  line: z.number(),
  severity: z.enum(["low", "medium", "high"]),
  issue: z.string(),
  suggested_fix: z.string(),
});
const AccessibilityAudit = z.object({
  pages_checked: z.number(),
  findings: z.array(Finding),
});
type AccessibilityAudit = z.infer<typeof AccessibilityAudit>;

const schema = z.toJSONSchema(AccessibilityAudit, { target: "draft-7" });

for await (const msg of query({
  prompt: "Audit the React components in src/pages for accessibility problems",
  options: { allowedTools: ["Read", "Glob", "Grep"], outputFormat: { type: "json_schema", schema } },
})) {
  if (msg.type === "result" && msg.subtype === "success" && msg.structured_output) {
    const parsed = AccessibilityAudit.safeParse(msg.structured_output);
    if (parsed.success) {
      const audit: AccessibilityAudit = parsed.data;
      for (const f of audit.findings.filter((x) => x.severity === "high")) {
        console.log(`${f.file}:${f.line} ${f.issue}`);
      }
    }
  }
}

Python with Pydantic:

from typing import Literal
from pydantic import BaseModel
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage

class Finding(BaseModel):
    file: str
    line: int
    severity: Literal["low", "medium", "high"]
    issue: str
    suggested_fix: str

class AccessibilityAudit(BaseModel):
    pages_checked: int
    findings: list[Finding]

opts = ClaudeAgentOptions(
    allowed_tools=["Read", "Glob", "Grep"],
    output_format={"type": "json_schema", "schema": AccessibilityAudit.model_json_schema()},
)

async def audit():
    async for msg in query(prompt="Audit the templates in app/templates for accessibility problems", options=opts):
        if isinstance(msg, ResultMessage) and msg.subtype == "success" and msg.structured_output:
            report = AccessibilityAudit.model_validate(msg.structured_output)
            print(f"{len(report.findings)} findings across {report.pages_checked} pages")

Structured output after real work

The point of doing this through the agent rather than a single API call is that Claude can investigate first. In this example it has to search the codebase, run git commands to find who introduced each item, then assemble the answer. Fields that may not be discoverable are left optional so the agent can omit them rather than invent them.

deprecation_schema = {
    "type": "object",
    "properties": {
        "calls": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "function": {"type": "string"},
                    "file": {"type": "string"},
                    "line": {"type": "number"},
                    "introduced_by": {"type": "string"},
                    "introduced_on": {"type": "string"},
                },
                "required": ["function", "file", "line"],
            },
        },
        "total": {"type": "number"},
    },
    "required": ["calls", "total"],
}

opts = ClaudeAgentOptions(
    allowed_tools=["Grep", "Read", "Bash"],
    output_format={"type": "json_schema", "schema": deprecation_schema},
)
prompt = "Find every call to functions marked @deprecated in this repo and use git blame to say who added each call"

The agent will typically use Grep to find candidates, Read to confirm them, and Bash to run git blame, then return one object.

Handling failures

Two subtypes matter here:

subtypeMeaning
successOutput produced and validated
error_max_structured_output_retriesNo valid output was left after the retries. Either every attempt failed validation, or a model fallback retracted a finished output mid-stream and no retry replaced it.

Check the errors list on the error result to tell those two causes apart before you start rewriting your schema.

There is a third case to guard against: subtype is success but structured_output is missing (Python None, TypeScript undefined). The run finished but nothing validated. Treat it as a failure too; the troubleshooting page explains it.

So the safe pattern is to trust the output only when both conditions hold:

if (msg.type === "result") {
  if (msg.subtype === "success" && msg.structured_output) {
    await saveSupplierTerms(msg.structured_output);
  } else if (msg.subtype === "error_max_structured_output_retries") {
    alertOps("Supplier check could not produce valid output", msg);
  } else {
    alertOps(`Supplier check ended without structured output (${msg.subtype})`, msg);
  }
}

Wrap the loop in try as well, since a single-message run raises after an error result.

Making failures rarer

  • Keep schemas small. Deep nesting with many required fields is harder to satisfy. Start minimal and add fields as you go.
  • Make uncertain fields optional. If the information might not exist, do not require it, or the agent will struggle (or worse, guess).
  • Write a clear prompt. Say what you want in the output, not just what to investigate.
  • Check the schema is satisfiable. Conflicting constraints, such as a minLength larger than maxLength, can never validate.

With streaming

If you turn on partial messages, the structured output streams as input_json_delta chunks of a tool call. Those chunks are unvalidated; only structured_output on the final result is guaranteed to match the schema.