DEV Community

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

Posted on Originally published at jsmanifest.com

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

This article was written with the assistance of AI, under human supervision and review.

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.

Problem flow showing subagent returning unstructured text that the lead must parse

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.

Solution flow showing subagent returning validated JSON schema

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

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.

Conceptual flow of structured output mode transforming subagent result into synthetic tool call

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");
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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" }
  )
});
Enter fullscreen mode Exit fullscreen mode

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 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.

Execution flow showing SubagentStop hook validating output before lead receives it

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");
}
Enter fullscreen mode Exit fullscreen mode

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.

Decision tree showing retry loop and eventual failure path for invalid structured output

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;
}
Enter fullscreen mode Exit fullscreen mode

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.

Multi-level workflow showing lead agent receiving composed results from nested subagents

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>;
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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:

Strategy Use case Validation overhead Type safety
Flat schema Single subagent, simple output Low (one validation) Full (single-level inference)
Nested schema Hierarchical analysis (repo → files) Medium (validation per level) Full (deep inference with nested types)
Array schema Parallel subagents returning same shape High (validation per element) Full (array element type inferred)
Discriminated union Subagents returning different shapes Medium (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>;
Enter fullscreen mode Exit fullscreen mode

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(", ")}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)