ASK KNOX
beta
LESSON 570

Structured Output That Survives Production

Green mocked tests cannot catch a production-illegal schema — the only proof is a live smoke call at deploy time.

6 min read·Building with Claude

The Incident That Commissioned This Lesson

A structured-output endpoint shipped behind green mocked tests. The schema — a raw JSON Schema object passed to output_config.format — used maxItems on a response array to guarantee the model would return a bounded result set. Every unit test passed. Every integration test passed against the mocked client.

The first real production request returned HTTP 400.

The maxItems constraint is in the server-side validator's rejected family, and a raw schema reaches the server exactly as written. No test ever caught that, because no test ever sent the schema to the server. The mocked tests simulated a response; they never tested whether the server would accept the schema in the first place. The team had tested their parsing logic, not their schema legality.

This is the lesson that came out of that incident. Not a debugging lesson — a structural one. The lesson is about where the validator actually lives, what it actually rejects, and the one test that proves a schema is production-legal.

How Structured Output Works

The canonical parameter is output_config on messages.create():

import Anthropic from "@anthropic-ai/sdk"

const client = new Anthropic()

const response = await client.messages.parse({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Classify this support ticket: payment not processing on checkout" }],
  output_config: {
    format: {
      type: "json_schema",
      schema: {
        type: "object",
        properties: {
          category: { type: "string", description: "Ticket category", enum: ["billing", "technical", "account", "other"] },
          priority: { type: "string", enum: ["low", "medium", "high", "critical"] },
          summary: { type: "string", description: "One-sentence summary" },
        },
        required: ["category", "priority", "summary"],
        additionalProperties: false,
      },
    },
  },
})

// Always check stop_reason before accessing parsed_output
if (response.stop_reason !== "end_turn") {
  throw new Error("Unexpected stop_reason: " + response.stop_reason)
}
console.log(response.parsed_output) // typed, validated object

The TypeScript helper client.messages.parse() with zodOutputFormat(schema) is the ergonomic path — it gives you a typed parsed_output field on the response. The old top-level output_format field is deprecated; use output_config.format.

A few facts about how this works: the first request with a new schema pays a one-time compile cost, which is then cached for 24 hours. Structured output is incompatible with citations. And assistant prefills — the old technique of starting the assistant turn with { to force JSON — return 400 on the current model family. Structured outputs replaced prefills entirely; mention them as history if a colleague asks.

The Validator Rules Matrix

Here is the part that matters. The server-side validator has a fixed constraint support matrix. It does not accept everything JSON Schema allows.

The critical column in that matrix is "THE TRAP." These rejections are operationally dangerous because there are two ways to ship a schema, and they fail in two different ways — neither of which a mocked test can see:

The raw-schema path. Pass a hand-written JSON Schema to output_config.format and the SDK sends it to the server exactly as written. An unsupported constraint like minItems: 2 reaches the real validator and the request returns 400 — but only on a live call. Mocked tests simulate the response and never send the schema anywhere, so the first request that can fail is the first real one.

The helper path. Build the schema with zodOutputFormat() (or jsonSchemaOutputFormat()) and the helper transforms unsupported constraints out of the wire schema before sending — your minItems: 2 stays in your in-memory schema object but is moved into description text on the wire. The server receives a legal schema and never 400s. Here the trap inverts: nothing fails, and your cardinality constraint is silently unenforced server-side. Only a behavioral assertion — actually checking the array length on a live response — catches it.

In both cases the trap is a gap between what your code believes the schema enforces and what the server actually validates. A smoke gate catches the raw-path 400; a behavioral assert in that same gate catches the helper path's silent non-enforcement.

The Schema You Should Write

Here is what a production-legal schema looks like:

const TRIAGE_SCHEMA = {
  type: "object" as const,
  properties: {
    category: {
      type: "string" as const,
      description: "Support ticket category",
      enum: ["billing", "technical", "account", "other"],
    },
    priority: {
      type: "string" as const,
      enum: ["low", "medium", "high", "critical"],
    },
    tags: {
      type: "array" as const,
      items: { type: "string" as const },
      description: "Relevant topic tags — no cardinality constraints (minItems/maxItems rejected)",
    },
    confidence: {
      type: "string" as const,
      enum: ["low", "medium", "high"],
      description: "Classification confidence — use enum not a numeric range",
    },
  },
  required: ["category", "priority", "tags", "confidence"],
  additionalProperties: false, // REQUIRED — omitting this returns 400
}

Notice what is absent: no minimum or maximum on a number field, no minLength or maxLength on strings, no minItems or maxItems on the tags array. If you need to bound the confidence value, use an enum instead of a numeric range. If you need a non-empty string, enforce it in your application layer, not in the schema.

Every object — including nested objects — needs additionalProperties: false. If a property contains a nested object, that nested object needs its own additionalProperties: false. The validator applies the rule recursively.

The Deploy-Gate Pipeline

This is the structural fix. Add a smoke gate stage at the end of your CI pipeline — after unit tests, after integration tests, before promotion to production.

The smoke gate is a single API call:

// ci/smoke-gate.ts — run after integration tests, before deploy
import Anthropic from "@anthropic-ai/sdk"
import { TRIAGE_SCHEMA } from "../src/schemas/triage"

const SMOKE_PROMPT = "Classify this test ticket: account login not working"

async function runSmokeGate() {
  const client = new Anthropic() // reads ANTHROPIC_API_KEY from env
  
  const result = await client.messages.parse({
    model: "claude-haiku-4-5", // use Haiku for the smoke gate — cheaper, fast
    max_tokens: 256,
    messages: [{ role: "user", content: SMOKE_PROMPT }],
    output_config: {
      format: { type: "json_schema", schema: TRIAGE_SCHEMA },
    },
  })

  if (result.stop_reason !== "end_turn") {
    console.error("Smoke gate FAIL: unexpected stop_reason", result.stop_reason)
    process.exit(1)
  }

  if (!result.parsed_output) {
    console.error("Smoke gate FAIL: parsed_output is null — schema may be rejected")
    process.exit(1)
  }

  console.log("Smoke gate PASS:", JSON.stringify(result.parsed_output, null, 2))
  process.exit(0)
}

runSmokeGate().catch((err) => {
  console.error("Smoke gate ERROR:", err.message)
  process.exit(1)
})

Two side effects worth noting. First, the smoke call pre-warms the schema compile cache for the first production request. Second, it exercises your stop_reason handling — if the model refuses, you catch it in CI, not in production.

Handling Refusals and Truncation

Two scenarios break structured output at runtime even with a production-legal schema.

stop_reason === "refusal" means Claude returned a refusal response. The stop_details field carries the refusal detail. In this case, parsed_output may not match the schema — surface the refusal to your application rather than attempting to parse. Do not retry the same input verbatim; the refusal was intentional.

stop_reason === "max_tokens" means the response was truncated before JSON was complete. The partial JSON cannot be parsed. Either increase max_tokens or stream the response and check message_delta.stop_reason before passing the full content to your parser.

The correct pattern is: always check stop_reason first. If it is end_turn, parse. If it is anything else, handle the specific case.

// `parsed_output` lives on the ParsedMessage returned by client.messages.parse(),
// not on a plain Anthropic.Message — type the param accordingly or the read won't compile.
function parseStructuredOutput<T>(response: Anthropic.Messages.ParsedMessage<T>): T {
  switch (response.stop_reason) {
    case "end_turn":
      if (!response.parsed_output) throw new Error("parsed_output null on end_turn — unexpected")
      return response.parsed_output as T
    case "refusal":
      throw new Error("Model refused: " + JSON.stringify(response.stop_details))
    case "max_tokens":
      throw new Error("Response truncated — increase max_tokens or switch to streaming")
    default:
      throw new Error("Unexpected stop_reason: " + response.stop_reason)
  }
}

The academy's own AI Tutor routes through structured output for every quiz evaluation. The schema is smoke-gated on every deploy, and the stop_reason switch runs on every response. Build yours the same way.

What's Next

The prompt-caching lesson takes the stateless-by-design observation from earlier in the track and shows how prompt caching makes it economical. Every turn re-sends full history — caching is the mechanism that turns that cost from a scaling problem into a non-issue.