The reliable way to get structured output from Claude is a tool call constrained by a JSON Schema input_schema, not asking for JSON in free text and parsing whatever comes back. Define a tool (name, description, input_schema), force the model to call it, and read the arguments straight off the tool_use content block — already a parsed object, not a string you have to regex out of a paragraph of preamble. Prompt-based JSON ("respond with a JSON object containing...") gives no structural guarantee at all: a stray sentence before the {, a trailing comma, or an unquoted key will periodically break a plain JSON.parse in production, and the failure rate goes up, not down, as your schema gets more complex.
tool_choice: auto, any, and a named tool
The tool_choice parameter is the actual lever, and it has three distinct modes:
{"type": "auto"}(the default) — the model decides whether to call a tool at all. It can legitimately return plain text instead. Fine for a conversational agent; wrong whenever you need guaranteed structured output, because "guaranteed" and "the model may choose not to" are contradictory.{"type": "any"}— the model must call some tool, but picks which one. Use this with multiple candidate schemas (extract_invoice,extract_receipt,extract_contract) when the input type is unknown ahead of time: structured output is guaranteed, tool selection stays flexible.{"type": "tool", "name": "extract_metadata"}— the model must call this specific tool. Use it to force a mandatory step (e.g. metadata extraction must run before an enrichment step), with zero flexibility.
const res = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
tool_choice: { type: "tool", name: "extract_invoice" },
tools: [extractInvoiceTool],
messages: [{ role: "user", content: documentText }],
});
const data = res.content.find(b => b.type === "tool_use").input;
// data is already a parsed object matching input_schema
What a schema does not guarantee
A schema constrains shape, not correctness. tool_use with a JSON Schema eliminates syntax errors — the arguments will always parse, always have the right types, always satisfy the enum/required constraints you declared. It does not stop the model from producing a field that's well-formed and still wrong: line items that don't sum to the stated total, a date parsed into the wrong field, or — the most damaging case — a plausible-looking fabricated value for a required field the source document never actually mentioned. Required fields specifically pressure fabrication: if the schema says a field must be present, the model is structurally incentivised to invent something rather than fail the call.
The schema-design fix is to make fields optional/nullable whenever the source may legitimately lack that information, so the honest answer (null) is available instead of forcing an invention:
{
"type": "object",
"properties": {
"invoice_number": { "type": "string" },
"vendor_name": { "type": "string" },
"payment_terms": { "type": ["string", "null"] },
"status": { "type": "string", "enum": ["paid", "unpaid", "overdue", "unclear"] }
},
"required": ["invoice_number", "vendor_name", "status"]
}Note the "unclear" value alongside the real enum options — it gives the model a legitimate way to express genuine ambiguity in a constrained field instead of forcing a guess between the real options. An "other" value paired with a freeform *_detail string field does the same job for open-ended categorisation.
Closing the gap: validation and bounded retry
Semantic validation is your code's job, not the schema's. After receiving the tool call, check the values your schema couldn't: does subtotal + tax == total? Is the enum value one your business logic actually expects, or a legitimately new case that slipped past a stale enum list? On failure, don't crash and don't silently accept the bad value — send a corrective follow-up turn that names the specific problem ("the line items summed to 42.50 but total was given as 45.00 — re-check the total"), append the original tool_use block plus a tool_result with is_error: true carrying that message, and let the model retry with the concrete correction in front of it. A generic "that was wrong, try again" retry wastes a turn: the model has no more information the second time than the first.
Always cap the retry count — two or three attempts, then fail loudly into a human-review path. An unbounded retry loop against a task the model genuinely can't do isn't more robust, it's just a slower, more expensive failure that still ends in an error, now with extra latency and token spend attached.
Common exam distractor
Asking Claude to "return valid JSON" in a plain-text prompt, without a tool schema, and then parsing the response text directly, is a fragile pattern the exam likes to present as a wrong answer — free-text JSON is more prone to formatting drift than a forced tool call. So is treating tool_choice: "auto" as sufficient when the requirement is guaranteed structured output: "auto" still lets the model return plain text instead.
Key concept
tool_use with a schema eliminates syntax errors, not semantic ones. Nullable/optional fields are the schema-level defence against fabrication; a bounded validation-and-retry loop in your own code is the defence against everything a schema structurally cannot check.