Skip to content
jsmanifest logojsmanifest

Claude Code Structured Output Mode in 2026: Forcing JSON Responses From Subagents and Validating Them With Zod

Claude Code Structured Output Mode in 2026: Forcing JSON Responses From Subagents and Validating Them With Zod

Disclosure: jsmanifest has a financial interest in NoCensor AI and may earn from signups through links in this article.

Pass outputFormat to subagent sessions, validate structured output with Zod schemas before results reach the lead agent, and compose multi-agent workflows with type-safe JSON pipelines in Claude Code SDK v0.3.301+.

Subagents in Claude Code return unstructured text by default. When a lead agent spawns five parallel subagents to extract metadata from API endpoints, the lead receives five prose summaries. Parsing those summaries for the actual data means regex patterns, fallback heuristics, and silent failures when a subagent changes its response format mid-conversation.

%% alt: Problem flow showing subagent returning unstructured text that the lead must parse
flowchart LR
    Lead("Lead agent spawns subagent")
    Sub("Subagent completes task")
    Text("Returns prose summary")
    Parse("Lead parses text with regex")
    Fail("Parsing fails silently")
    
    Lead --> Sub
    Sub --> Text
    Text --> Parse
    Parse --> Fail
    
    style Fail stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Problem flow showing subagent returning unstructured text that the lead must parse

Structured output mode forces subagents to return validated JSON that matches a schema you define. The lead receives the exact shape it expects, and Zod validates every field before the result reaches the lead's context. No parsing, no silent failures.

%% alt: Solution flow showing subagent returning validated JSON schema
flowchart LR
    Lead("Lead agent spawns subagent")
    Sub("Subagent with outputFormat")
    Schema("Returns validated JSON")
    Type("Lead receives typed object")
    Safe("Type-safe downstream use")
    
    Lead --> Sub
    Sub --> Schema
    Schema --> Type
    Type --> Safe
    
    style Safe stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Solution flow showing subagent returning validated JSON schema

This post shows how to pass outputFormat to subagent sessions, validate structured output with Zod before results surface to the lead, and compose JSON schemas for multi-agent workflows that demand reliability. Examples run on Claude Code SDK v0.3.301 with Claude Fable 5.1 (claude-fable-5-1), which dropped support for tool_choice types any and tool in the September 1, 2026 release. The structured output and strict tool use patterns here replace the removed tool-choice guarantees.

Claude Code structured output validation workflow

Key Takeaways

  • Structured output mode forces subagents to return validated JSON matching a schema, eliminating regex parsing and silent format drift.
  • Pass outputFormat: { type: "json_schema", schema: zodSchema } to startSubagent() to constrain a subagent's final result.
  • Zod schemas generate JSON Schema with .toJSONSchema(schema, { target: "draft-7" }) and provide TypeScript types through inference.
  • The SubagentStop hook fires before a subagent's result reaches the lead, so Zod validation there catches schema violations before context pollution.
  • On validation failure, the hook can reject the subagent outright or extend its turn with correction prompts via additionalContext.

Understanding Claude Code Structured Output Mode: The Synthetic Tool Pattern

Structured output mode wraps the subagent's final response in a synthetic tool call. The SDK generates a tool definition from the schema you provide, instructs the model to call that tool with the result, validates the call against the schema, and surfaces the validated JSON to the lead. The subagent still uses any tools it needs during execution (Grep, Bash, file operations), but the final turn must conform to the structure you defined.

%% alt: Conceptual flow of structured output mode transforming subagent result into synthetic tool call
flowchart TD
    Start("Subagent executes with tools")
    Tools("Uses Grep Bash Read Write")
    Synth("SDK wraps result in synthetic tool")
    Valid("Validates against JSON Schema")
    Lead("Lead receives structured_output")
    Retry("Max retries exceeded")
    
    Start --> Tools
    Tools --> Synth
    Synth --> Valid
    Valid -->|pass| Lead
    Valid -->|fail| Retry
    
    style Lead stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style Retry stroke:#ef4444,fill:#450a0a,color:#fca5a5
Conceptual flow of structured output mode transforming subagent result into synthetic tool call

The schema is a JSON Schema object. Zod schemas convert to JSON Schema with zodToJsonSchema() from the zod-to-json-schema package. The SDK supports standard JSON Schema features: object, array, string, number, boolean, null, enum, const, required, nested objects, and $ref definitions. The format keyword (like "format": "email") is accepted as an annotation but not enforced. Before SDK v2.1.205, invalid schemas were silently ignored and the subagent returned unstructured text. After v2.1.205, an invalid schema fails the run at startup with an error naming the problem.

The subagent attempts the synthetic tool call up to the retry limit (default 3). If validation fails on every attempt, the result becomes an error of type error_max_structured_output_retries. The lead sees the error in the subagent's summary unless your SubagentStop hook intercepts it first. Production workflows often validate in the hook and handle the error there, so the lead never receives a malformed payload.

Forcing JSON Responses From Subagents: Passing outputFormat to Subagent Sessions

A subagent session accepts outputFormat in the same shape as the lead's query() call. Define a Zod schema, convert it to JSON Schema, and pass it to startSubagent(). The example below spawns a subagent to extract repository metadata and return it as a typed object:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { startSubagent } from "@anthropic-ai/claude-agent-sdk";
 
const repoMetadataSchema = z.object({
  name: z.string().describe("Repository name"),
  primaryLanguage: z.string().describe("Primary programming language"),
  stars: z.number().int().nonnegative().describe("Star count"),
  openIssues: z.number().int().nonnegative().describe("Open issue count"),
  lastCommitDate: z.string().describe("ISO 8601 date of last commit")
});
 
type RepoMetadata = z.infer<typeof repoMetadataSchema>;
 
const jsonSchema = zodToJsonSchema(repoMetadataSchema, { 
  target: "draft-7" 
});
 
async function analyzeRepository(repoPath: string): Promise<RepoMetadata> {
  const messages = [];
  
  for await (const message of startSubagent({
    prompt: `Analyze the repository at ${repoPath} and extract metadata`,
    options: {
      outputFormat: {
        type: "json_schema",
        schema: jsonSchema
      }
    }
  })) {
    messages.push(message);
    
    if (message.type === "result" && message.subtype === "success") {
      if (!message.structured_output) {
        throw new Error("Expected structured output but received none");
      }
      return message.structured_output as RepoMetadata;
    }
    
    if (message.type === "result" && message.subtype === "error") {
      throw new Error(`Subagent failed: ${message.error}`);
    }
  }
  
  throw new Error("Subagent completed without result");
}

The subagent uses Bash to run git log, Grep to search for language indicators, and Read to examine configuration files. The final result matches the schema or triggers a retry. The lead receives a typed RepoMetadata object with no parsing layer.

Optional fields follow Zod's .optional() pattern. The JSON Schema includes them in properties but omits them from required. When the subagent cannot populate an optional field (like an author name for a file with no git history), it omits the key from the synthetic tool call, and TypeScript infers the field as string | undefined:

const todoSchema = z.object({
  text: z.string(),
  file: z.string(),
  line: z.number().int().positive(),
  author: z.string().optional(),
  date: z.string().optional()
});
 
type Todo = z.infer<typeof todoSchema>;

The author and date keys appear in the JSON Schema with no required constraint. The subagent populates them when git blame data exists and omits them otherwise. Downstream code checks todo.author !== undefined before using the value.

Validating Structured Output With Zod: From JSON Schema to Runtime Type Safety

Zod provides both the JSON Schema for the SDK and the runtime validator for the result. The parse() method throws on validation failure. The safeParse() method returns { success: true, data: T } or { success: false, error: ZodError }. Production code often uses safeParse() in a SubagentStop hook to catch malformed output before it reaches the lead.

The schema from the previous section generates this JSON Schema, which the SDK passes to the model:

{
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "description": "Repository name"
    },
    "primaryLanguage": {
      "type": "string",
      "description": "Primary programming language"
    },
    "stars": {
      "type": "integer",
      "minimum": 0,
      "description": "Star count"
    },
    "openIssues": {
      "type": "integer",
      "minimum": 0,
      "description": "Open issue count"
    },
    "lastCommitDate": {
      "type": "string",
      "description": "ISO 8601 date of last commit"
    }
  },
  "required": ["name", "primaryLanguage", "stars", "openIssues", "lastCommitDate"],
  "additionalProperties": false
}

Runtime validation applies the same constraints. Zod checks that stars is a nonnegative integer and lastCommitDate is a string. Custom refinements enforce stricter rules:

const repoMetadataSchema = z.object({
  name: z.string().min(1),
  primaryLanguage: z.string().min(1),
  stars: z.number().int().nonnegative(),
  openIssues: z.number().int().nonnegative(),
  lastCommitDate: z.string().refine(
    (val) => !isNaN(Date.parse(val)),
    { message: "Must be a valid ISO 8601 date" }
  )
});

The refinement on lastCommitDate rejects malformed date strings like "2026-13-01". The SDK's validation catches basic type mismatches, and Zod's refinement catches semantic errors. When a subagent returns "2026-13-01", the synthetic tool call fails validation, the SDK retries, and the subagent eventually corrects itself or exhausts retries.

Zod validation pipeline from JSON Schema to runtime type checking

Zod v4 (released in late 2025) changed the refinement API and the shape of ZodError. The zodToJsonSchema() function supports both Zod v3 and v4. Projects upgrading to Zod v4 must update error-handling code that inspects error.issues directly, because the issues array structure changed. The JSON Schema output remains compatible across versions.

SubagentStop Hook Integration: Validating Output Before It Reaches the Lead Agent

The SubagentStop hook fires when a subagent finishes and before its summary reaches the lead. A hook firing in SDK v0.3.301+ receives hookSpecificOutput.additionalContext, so validation failures can extend the subagent's turn with correction prompts instead of terminating the subagent outright. The hook returns behavior: "cancel" to reject the result or behavior: "allow" to forward it to the lead.

%% alt: Execution flow showing SubagentStop hook validating output before lead receives it
flowchart LR
    Sub("Subagent completes")
    Hook("SubagentStop hook fires")
    Zod("Zod safeParse validation")
    Pass("validation passes")
    Lead("Lead receives typed result")
    Fail("validation fails")
    Retry("Extend subagent with correction prompt")
    
    Sub --> Hook
    Hook --> Zod
    Zod --> Pass
    Pass --> Lead
    Zod --> Fail
    Fail --> Retry
    
    style Lead stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style Zod stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
    style Fail stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Execution flow showing SubagentStop hook validating output before lead receives it

The example below validates repository metadata in the hook and extends the subagent's turn on failure:

import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { query } from "@anthropic-ai/claude-agent-sdk";
 
const repoMetadataSchema = z.object({
  name: z.string().min(1),
  primaryLanguage: z.string().min(1),
  stars: z.number().int().nonnegative(),
  openIssues: z.number().int().nonnegative(),
  lastCommitDate: z.string().refine(
    (val) => !isNaN(Date.parse(val)),
    { message: "Must be a valid ISO 8601 date" }
  )
});
 
type RepoMetadata = z.infer<typeof repoMetadataSchema>;
 
const jsonSchema = zodToJsonSchema(repoMetadataSchema, { 
  target: "draft-7" 
});
 
async function analyzeWithValidation(repoPath: string): Promise<RepoMetadata> {
  for await (const message of query({
    prompt: `Spawn a subagent to analyze the repository at ${repoPath}`,
    options: {
      subagentStop: async (payload) => {
        const output = payload.hookSpecificOutput.structuredOutput;
        
        if (!output) {
          return {
            behavior: "cancel",
            additionalContext: "No structured output received"
          };
        }
        
        const result = repoMetadataSchema.safeParse(output);
        
        if (!result.success) {
          const errors = result.error.issues
            .map(issue => `${issue.path.join(".")}: ${issue.message}`)
            .join("; ");
          
          return {
            behavior: "allow",
            additionalContext: `Validation failed: ${errors}. Correct the output and try again.`
          };
        }
        
        return { behavior: "allow" };
      }
    }
  })) {
    if (message.type === "result" && message.subtype === "success") {
      return message.structured_output as RepoMetadata;
    }
  }
  
  throw new Error("Query completed without result");
}

The hook receives hookSpecificOutput.structuredOutput containing the subagent's JSON output before the lead sees it. Validation failures produce a correction prompt in additionalContext, and the subagent continues with the feedback. The hook returns behavior: "allow" so the subagent remains active. When validation passes, the hook returns behavior: "allow" with no additionalContext, and the lead receives the validated result.

The pattern scales to multiple subagents. A lead spawning ten parallel subagents passes the same subagentStop hook to all of them. Each subagent's output validates independently, and only valid results reach the lead. Invalid results trigger correction turns inside the subagent, invisible to the lead until the subagent produces valid output or exhausts retries.

Error Handling: What Happens When Structured Output Validation Fails

The SDK retries failed validation up to the retry limit (default 3). After the limit, the result becomes an error of type error_max_structured_output_retries. The lead sees this error in the subagent's summary unless the SubagentStop hook cancels the subagent first. Production workflows typically catch the error in the hook and log it for debugging, then cancel the subagent so the lead does not receive a broken result.

%% alt: Decision tree showing retry loop and eventual failure path for invalid structured output
flowchart TD
    Call("Subagent makes synthetic tool call")
    Valid("SDK validates against schema")
    Pass("Validation passes")
    Lead("Lead receives result")
    Fail("Validation fails")
    Retry("Increment retry count")
    Check("Count < limit?")
    Again("Retry with feedback")
    Error("error_max_structured_output_retries")
    
    Call --> Valid
    Valid -->|pass| Pass
    Pass --> Lead
    Valid -->|fail| Fail
    Fail --> Retry
    Retry --> Check
    Check -->|yes| Again
    Again --> Call
    Check -->|no| Error
    
    style Lead stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style Error stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style Fail stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Decision tree showing retry loop and eventual failure path for invalid structured output

The retry loop is automatic. The SDK provides feedback to the model after each failed attempt, describing the validation error and the expected schema. The model corrects its response and tries again. When retries exhaust without a valid result, the error surfaces. The example below catches the error in the main query loop:

async function analyzeWithErrorHandling(repoPath: string): Promise<RepoMetadata | null> {
  try {
    for await (const message of query({
      prompt: `Spawn a subagent to analyze the repository at ${repoPath}`,
      options: {
        subagentStop: async (payload) => {
          const output = payload.hookSpecificOutput.structuredOutput;
          
          if (!output) {
            console.error("Subagent produced no structured output");
            return { behavior: "cancel" };
          }
          
          const result = repoMetadataSchema.safeParse(output);
          
          if (!result.success) {
            console.error("Validation failed:", result.error.issues);
            return { behavior: "cancel" };
          }
          
          return { behavior: "allow" };
        }
      }
    })) {
      if (message.type === "result" && message.subtype === "success") {
        return message.structured_output as RepoMetadata;
      }
      
      if (message.type === "result" && message.subtype === "error") {
        console.error("Subagent error:", message.error);
        return null;
      }
    }
  } catch (error) {
    console.error("Query threw:", error);
    return null;
  }
  
  return null;
}

The SubagentStop hook cancels the subagent when validation fails. The lead receives a result message with subtype: "error", and the main loop returns null. Downstream code checks for null and handles the missing data accordingly.

Some validation failures are unrecoverable. When the schema expects an integer and the subagent's tools can only return a string, retries will exhaust without progress. The hook pattern above logs the failure and terminates early, saving tokens and latency. Production systems often include a fallback path: if structured output fails, the lead spawns a second subagent with a simpler schema or a prose-based extraction prompt.

Production Patterns: Composing Subagent JSON Schemas for Multi-Agent Workflows

Multi-agent workflows compose schemas hierarchically. A lead spawns a subagent to analyze a repository, and that subagent spawns child subagents to analyze individual files. Each level defines its own schema, and the parent's schema includes the child's schema as a nested object or array.

%% alt: Multi-level workflow showing lead agent receiving composed results from nested subagents
flowchart LR
    Lead("Lead agent defines top-level schema")
    Sub1("Subagent 1 with file analysis schema")
    Sub2("Subagent 2 with file analysis schema")
    Compose("Lead composes results into repository schema")
    Typed("Fully typed repository object")
    
    Lead --> Sub1
    Lead --> Sub2
    Sub1 --> Compose
    Sub2 --> Compose
    Compose --> Typed
    
    style Compose stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
    style Typed stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Multi-level workflow showing lead agent receiving composed results from nested subagents

The repository metadata schema from earlier examples extends to include an array of file analyses:

const fileAnalysisSchema = z.object({
  path: z.string(),
  language: z.string(),
  lines: z.number().int().positive(),
  complexity: z.number().int().nonnegative(),
  lastModified: z.string()
});
 
const repoAnalysisSchema = z.object({
  name: z.string().min(1),
  primaryLanguage: z.string().min(1),
  stars: z.number().int().nonnegative(),
  openIssues: z.number().int().nonnegative(),
  lastCommitDate: z.string(),
  files: z.array(fileAnalysisSchema)
});
 
type RepoAnalysis = z.infer<typeof repoAnalysisSchema>;

The lead spawns a subagent with outputFormat set to the repoAnalysisSchema. That subagent spawns child subagents to analyze individual files, each with outputFormat set to fileAnalysisSchema. The lead receives a single RepoAnalysis object with fully typed nested data. Validation at each level guarantees that every file analysis conforms to fileAnalysisSchema and the repository analysis conforms to repoAnalysisSchema.

The composition pattern scales to arbitrary nesting depth. A subagent analyzing a file might spawn subagents to analyze functions, and those subagents return function-level schemas. The top-level lead receives a deeply nested typed object with no manual assembly.

Parallel subagents often return arrays. A lead spawning ten subagents to analyze ten repositories defines a schema like this:

const batchAnalysisSchema = z.object({
  repositories: z.array(repoAnalysisSchema),
  totalStars: z.number().int().nonnegative(),
  mostActiveRepo: z.string()
});

The lead spawns ten child subagents, each returning a RepoAnalysis object. The lead collects the results, computes totalStars and mostActiveRepo, and returns a batchAnalysisSchema-shaped object. The parent that spawned the lead receives a single typed payload summarizing ten repositories.

Comparison of schema composition strategies:

StrategyUse caseValidation overheadType safety
Flat schemaSingle subagent, simple outputLow (one validation)Full (single-level inference)
Nested schemaHierarchical analysis (repo → files)Medium (validation per level)Full (deep inference with nested types)
Array schemaParallel subagents returning same shapeHigh (validation per element)Full (array element type inferred)
Discriminated unionSubagents returning different shapesMedium (one validation, branching types)Full (union type inferred)

Discriminated unions handle cases where subagents return different shapes based on runtime conditions. A subagent analyzing a file might return different schemas for source files versus configuration files:

const sourceFileSchema = z.object({
  type: z.literal("source"),
  path: z.string(),
  language: z.string(),
  functions: z.array(z.string())
});
 
const configFileSchema = z.object({
  type: z.literal("config"),
  path: z.string(),
  format: z.string(),
  keys: z.array(z.string())
});
 
const fileSchema = z.discriminatedUnion("type", [
  sourceFileSchema,
  configFileSchema
]);
 
type FileAnalysis = z.infer<typeof fileSchema>;

The subagent returns a type field that determines the shape. TypeScript narrows the type based on the discriminant:

function processFile(file: FileAnalysis) {
  if (file.type === "source") {
    console.log(`Functions: ${file.functions.join(", ")}`);
  } else {
    console.log(`Config keys: ${file.keys.join(", ")}`);
  }
}

This pattern is common in workflows where a subagent inspects a file and decides the appropriate analysis strategy at runtime. The lead receives a single schema that accommodates both outcomes, and TypeScript enforces exhaustive handling of the union.

Frequently Asked Questions

What happens if a subagent exhausts retries without producing valid structured output?

The SDK yields a result message with type: "result", subtype: "error", and error containing error_max_structured_output_retries. If your SubagentStop hook cancels the subagent on validation failure, the lead receives an error result and can handle it with a fallback strategy. If the hook does not cancel, the lead sees the retry-exhaustion error in the subagent's summary.

Can structured output mode work with custom tools defined in the subagent session?

Structured output mode wraps the final result in a synthetic tool call, separate from any custom tools the subagent uses during execution. The subagent can call custom tools (like a database query tool) as many times as needed, then return the final result via the synthetic tool. Custom tools do not need to match the output schema; only the final result does.

How do I migrate from regex parsing to structured output mode in an existing workflow?

Replace the prose prompt (like "return the data as JSON") with an outputFormat option. Define a Zod schema matching the shape your regex currently extracts, convert it to JSON Schema with zodToJsonSchema(), and pass it to startSubagent(). Remove the regex parsing code and read message.structured_output directly. Add a SubagentStop hook to validate the output with Zod before it reaches the lead, catching any edge cases your regex missed.

Does structured output mode increase latency compared to unstructured responses?

Structured output mode adds one synthetic tool call at the end of the subagent's execution. Latency increases by the time to generate and validate that call (typically 100-300ms for Claude Fable 5.1). Retries on validation failure add incremental latency, but the retry loop is automatic and parallel across multiple subagents, so ten parallel subagents with retries often complete faster than sequential prose parsing.

Can I use Zod v4 refinements with the Claude Code SDK?

Zod v4 refinements work with the SDK because zodToJsonSchema() supports both Zod v3 and v4. The refinement runs at runtime in your SubagentStop hook or result handler, not inside the SDK's validation. The SDK validates basic types against the JSON Schema, and Zod's safeParse() applies refinements like date format checks or custom business logic.

Conclusion: Building Reliable Agent Pipelines With Structured Output and Zod

Structured output mode in Claude Code eliminates regex parsing from multi-agent workflows. Subagents return validated JSON matching schemas you define, and Zod enforces type safety from the schema definition through runtime validation. The SubagentStop hook integrates validation before results reach the lead, so invalid payloads trigger correction turns inside the subagent or terminate early. Production pipelines compose schemas hierarchically, spawning subagents that return typed objects fitting directly into the parent's schema. Related patterns for cost control and CI integration appear in Claude Code cost control for production workflows and CI agentic review with auto-fix. Compare the 2026 tooling landscape in Claude Code versus Cursor and Windsurf.