A year ago, the LLM-output bug was almost-JSON. The model wrapped its answer in markdown fences, JSON.parse threw, and you added a retry loop. Native structured outputs ended that.
The new failure is quieter. Teams read "guaranteed schema-compliant responses" in the docs and delete their checks along with the retry loop. The output always parses now. Nobody promised it was true. LLM output is still user input.
Can I just give an agent access to my database? (Partner)
Every AI project runs into this question. Before you wire an agent to business data over MCP, decide what it can see, how much each result costs in context, and when to give it reviewed queries instead of letting it write its own.
(Thanks to Oracle for sponsoring this issue.)
What structured outputs guarantee
Pass a JSON schema in output_config.format, or set strict: true on a tool, and Anthropic's API constrains decoding to that schema. Missing fields, wrong types and fenced JSON are gone. That guarantee covers the schema the API receives. It says nothing about whether the values are true, and that schema may not even be the one you wrote.
Five failures that still get through
1. Legal values that are wrong
An employeeId that belongs to nobody. A refund three times the original charge. A "travel" category on a software invoice. Every check passes, because a schema describes shapes and says nothing about your data.
OpenAI's structured-outputs guide warns that the model "will always try to adhere to the provided schema, which can result in hallucinations if the input is completely unrelated to the schema". Send a photo of a lunch menu to an expense schema and you still get a well-formed expense.
2. IDs that point at nothing
Models invent plausible identifiers. Spracklen and colleagues checked 576,000 code samples from 16 models. On average, at least 5.2% of the packages from commercial models and 21.7% from open-source models didn't exist (USENIX Security 2025). A package name passes any schema, and so does your employeeId. Look up every ID before you act on it.
3. Rules your SDK never sends
Anthropic's schema subset has no numeric bounds or string lengths. The SDKs move those rules into the field's description and check the response after it lands. The TypeScript SDK moves more. In @anthropic-ai/sdk 1.6.0, the current release, zodOutputFormat keeps types, required fields, ten string formats and minItems of 0 or 1. Everything else becomes description text, even z.enum():
"amountCents": { "type": "integer", "description": "{exclusiveMinimum: 0, maximum: 9007199254740991}" },
"category": { "type": "string", "description": "{enum: [\"travel\",\"meals\",\"software\",\"other\"]}" }The decoder never enforces a description. Only your Zod parse catches amountCents: 0 or a category of "approved ✅". The docs list enum as supported, so a later release may fix this. Print your own wire schema before you trust a rule.
4. Refusals and cut-offs
A refusal comes back as stop_reason: "refusal", with HTTP 200 and billed tokens. A response that hits your token cap comes back as stop_reason: "max_tokens", with JSON that just stops. Neither has to match your schema.
messages.parse() never reads stop_reason. A refusal with text, or a cut-off, throws the same AnthropicError as a bad value. Your retry logic can't tell "the model said no" from "the model got a field wrong".
5. Drift inside the schema
A prompt tweak or a model upgrade shifts the values without tripping a validator. category: "other" creeps from 5% of extractions to 30%, and no alert fires because every output is valid. Only a chart catches drift like this.
Validate three things, in order
Call messages.create() instead of parse(), so you can read stop_reason before you validate. The same Zod schema builds the wire schema and checks the rules it lost:
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const Expense = z.object({
employeeId: z.string(),
amountCents: z.number().int().positive(), // not in the wire schema
category: z.enum(["travel", "meals", "software", "other"]),
});
const expenseFormat = zodOutputFormat(Expense);
const client = new Anthropic();
export async function extractExpense(receiptText: string) {
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: receiptText }],
output_config: { format: expenseFormat },
});
// 1. Did the model finish? A refusal or a cut-off isn't a bad value.
if (message.stop_reason === "refusal" || message.stop_reason === "max_tokens") {
return sendToReview(message);
}
// 2. Shape, plus the rule the decoder never saw: amountCents > 0.
const text = message.content.find((block) => block.type === "text")?.text ?? "";
const expense = expenseFormat.parse(text);
// 3. Claims only your own data can check.
await assertEmployeeExists(expense.employeeId);
return expense;
}Give each step its own policy. Send refusals and cut-offs to review, because a retry won't change the answer. Retry a bad value once with the error fed back. Reject an unknown ID loudly. Then chart the pass rate per prompt version and the spread of fields like category, so you see drift in the week it starts.
Keep validating the claims
Structured outputs solved the syntax half of validation. The other half depends on your data, like your employee table, your refund rules, and the fact that a menu isn't a receipt. No schema can check those, so keep the validation step and point it at the claims.
📌 TL;DR
Structured outputs guarantee shape, not truth. Wrong but legal values and invented IDs pass every schema check.
zodOutputFormatsends less than you wrote. In@anthropic-ai/sdk1.6.0,.positive()andz.enum()reach the model as description hints, and only your Zod parse enforces them.messages.parse()ignoresstop_reason. Callcreate(), route refusals and cut-offs to review, then parse, then check IDs against your data.
This is the deep dive behind item 7 in 7 Things That Break LLM Apps in Production.
👋 P.S. If this issue was useful, Chapter 19 of my upcoming course, The Senior JavaScript Interview, covers Node.js API design. It’s one of 3 free chapters you can read now, before the course opens on 23 November.

Follow me on LinkedIn | Twitter(X) | Threads
Thank you for supporting this newsletter.
Consider sharing this post with your friends and get rewards.
You are the best! 🙏

Press the Like button if you found this post helpful




'LLM output is still user input' is the line I'd pin above every schema file. Anyone who has handled form submissions knows the same rule: a submission that matches every field type can still be junk, so the check that matters runs against your own data. The stop_reason point surprised me most, since a refusal returning 200 looks like success in most logs. Do you track refusals separately so they show up in the drift chart too?