The problem: LLMs break JSON
If you have shipped an LLM feature, you know this one. You ask the model for "a JSON object with fields name, email, score" and 99% of the time that is exactly what comes back. The remaining 1% looks like this:
- The reply opens with a
```jsonfence and the parser chokes. - A comment such as
// note: score estimateddangles at the end. - A trailing comma makes
JSON.parsethrow. - A quote inside a string was never escaped.
- A field turned into
e_mailbecause the model thought it looked nicer. - Half the reply is markdown explaining what the JSON means.
At a million calls a month, 1% is ten thousand silent failures in production. Free-form text generation is a poor fit for contracts between machines.
OpenAI shipped response_format: { type: "json_schema", strict: true } in 2024 and effectively closed the question on its own stack: decoding is constrained at the token level, so the output is guaranteed by grammar to pass the schema. Anthropic went another way. As of 2026 the native Messages API still has no json_mode flag. Instead Anthropic points you at a feature it already had: tool use.
This article covers the pattern we recommend for getting 100% valid, schema-conformant JSON out of Claude. We go through the native API with a forced tool_use, the OpenAI-compatible response_format shim, schema generation from Pydantic and Zod, a validate-and-retry loop, and a comparison with GPT-4o's native json_schema mode.
Every example goes through the codexkey gateway at the base URL https://api.codexkey.online/v1. It serves both the native Anthropic Messages API and an OpenAI-compatible Chat Completions endpoint. For prices and the model list, see Claude API pricing 2026.
Anthropic's answer: forced tool_use
Claude's tool use was built for agent workflows, so the model could call get_weather or search_db. The mechanism it uses to emit tool calls is also exactly what structured output needs. The model produces a tool_use block, and its input field is always a valid JSON object matching the tool's input_schema.
Anthropic's decoder enforces that. Nobody asks the model nicely to produce JSON: the tool input is the JSON. Add tool_choice: { type: "tool", name: "..." } to force a call to exactly that one tool, and you have a structured-output API in everything but name.
The recipe:
- Define the output shape as a Pydantic model (Python) or a Zod schema (TypeScript).
- Convert it to a JSON Schema.
- Wrap it in a single tool with
input_schema = <your JSON Schema>. - Send the request with
tools=[that_tool]andtool_choice={"type":"tool","name":"<your tool name>"}. - Read
response.content[0].input. That is your validated object.
Python: Pydantic → JSON Schema → tool_use
import anthropic
from pydantic import BaseModel, Field
from typing import Literal
class InvoiceLineItem(BaseModel):
description: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
class Invoice(BaseModel):
invoice_number: str
issued_on: str = Field(description="ISO-8601 date, e.g. 2026-04-03")
currency: Literal["USD", "EUR", "RUB", "GBP"]
vendor_name: str
line_items: list[InvoiceLineItem]
total_cents: int
client = anthropic.Anthropic(
base_url="https://api.codexkey.online/v1",
api_key="sk-cxa-...",
)
extract_tool = {
"name": "record_invoice",
"description": "Record a parsed invoice into the accounting system.",
"input_schema": Invoice.model_json_schema(),
}
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
tools=[extract_tool],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=[
{"role": "user", "content": f"Extract the invoice:\n\n{raw_invoice_text}"}
],
)
raw = resp.content[0].input # already a dict, already valid against the schema
invoice = Invoice.model_validate(raw) # belt-and-suspenders Pydantic check
resp.content[0].input comes back as a Python dict that already parses cleanly and already satisfies the JSON Schema you sent. The extra Invoice.model_validate(raw) call is insurance: it applies Pydantic's type coercion (the string "42" becomes the int 42) and runs your custom validators.
TypeScript: Zod → JSON Schema → tool_use
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
const SupportTicket = z.object({
category: z.enum(["billing", "bug", "feature_request", "abuse", "other"]),
urgency: z.enum(["low", "normal", "high", "critical"]),
summary: z.string().max(280),
suggested_owner_team: z.enum(["payments", "platform", "growth", "trust_safety"]),
contains_pii: z.boolean(),
});
const client = new Anthropic({
baseURL: "https://api.codexkey.online/v1",
apiKey: process.env.CODEXKEY_KEY!,
});
const resp = await client.messages.create({
model: "claude-sonnet-5",
max_tokens: 1024,
tools: [{
name: "classify_ticket",
description: "Classify an inbound customer support ticket.",
input_schema: zodToJsonSchema(SupportTicket, { target: "openAi" }) as any,
}],
tool_choice: { type: "tool", name: "classify_ticket" },
messages: [{ role: "user", content: ticketBody }],
});
const block = resp.content.find((b) => b.type === "tool_use");
const ticket = SupportTicket.parse(block?.input);
Note target: "openAi" on zodToJsonSchema. It produces a JSON Schema variant that both Claude and OpenAI accept without complaint. Zod's default target emits $ref patterns that the Anthropic API rejects.
OpenAI-compatible: response_format through the codexkey gateway
If your code is already written against the OpenAI SDK, the codexkey gateway accepts the OpenAI Chat Completions shape and translates response_format into a forced tool_use internally. Nothing needs rewriting.
from openai import OpenAI
client = OpenAI(
base_url="https://api.codexkey.online/v1",
api_key="sk-cxa-...",
)
resp = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": resume_text}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "Resume",
"strict": True,
"schema": Resume.model_json_schema(),
},
},
)
resume = Resume.model_validate_json(resp.choices[0].message.content)
This is the shortest path off OpenAI. Same SDK, same response_format semantics, Claude-quality output.
Three concrete examples
1. Extract an invoice
The input is noisy text pulled out of a PDF invoice. The output is the Invoice model from the Python snippet above. A forced tool_use means no more regexes for scraping line items: the model returns a typed list, and the code downstream reads invoice.line_items[0].unit_price_cents directly.
2. Classify a support ticket
Take the SupportTicket Zod schema. The category and urgency enums constrain the model, so you can route on the result without defensive .toLowerCase() calls scattered around. contains_pii is a boolean that goes straight into a redaction pipeline.
3. Parse a resume
class WorkExperience(BaseModel):
company: str
title: str
started_on: str = Field(description="YYYY-MM")
ended_on: str | None = Field(description="YYYY-MM or null if current")
highlights: list[str]
class Resume(BaseModel):
full_name: str
email: str | None
phone: str | None
years_of_experience: int = Field(ge=0, le=60)
skills: list[str]
experience: list[WorkExperience]
Add the same tool_use plumbing and you have a resume-parsing API.
Schema design tips
After a few dozen of these pipelines in production, a handful of habits matter more than the rest.
- Use
enumaggressively. A field whose values come from a finite set should be an enum. The model picks from a list far more accurately than it generates a value freely. - Descriptions work like prompts. Claude reads the
descriptionof every field. Hints such as "ISO-8601 date" or "phone number in E.164 format" change the output. - Keep nesting shallow. Two levels is the sweet spot. At three or four, the model starts losing track of which
{it is inside, even with forced tool use. - For three states, prefer a string enum to booleans.
status: "approved" | "rejected" | "needs_review"is easier for the model to reason about than two independent booleans. - Mark optional fields explicitly.
Optional[str]in Pydantic,.optional()in Zod. The model then leaves a field out cleanly instead of inventing"unknown". - Do not ask for free text inside structured fields. A
summary: strat the very end is fine. Asummaryfollowed by more structured fields tends to leak prose into its neighbours.
Validation and retry loop
Even with a forced tool_use, your business validators (say, "total_cents must equal the sum of the line items") can still fail. The pattern is simple:
def call_with_retry(messages, max_attempts=3):
for attempt in range(max_attempts):
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
tools=[extract_tool],
tool_choice={"type": "tool", "name": "record_invoice"},
messages=messages,
)
raw = resp.content[0].input
try:
return Invoice.model_validate(raw)
except ValidationError as e:
messages.append({"role": "assistant", "content": resp.content})
messages.append({
"role": "user",
"content": f"That output failed validation: {e}. Please call the tool again with corrected values.",
})
raise RuntimeError("Schema validation failed after retries")
One retry usually fixes it. On the second attempt Claude sees its own earlier output and the validation error in context, and it corrects itself reliably.
GPT-4o json_schema versus Claude's forced tool_use
Both approaches deliver schema-conformant JSON in production. In practice they differ like this:
- What backs the guarantee. OpenAI's
strict: truerelies on constrained decoding at the token level, so the model literally cannot emit invalid JSON. Anthropic's forcedtool_userelies on a validator on the tool input. At the API surface you will not see a difference (both return valid JSON every time), but if Claude does fail, it shows up as "the call returned an error" and not as "the JSON is malformed". - Which schema features work. OpenAI's strict mode disallows
oneOf,not, recursive refs and a few other JSON Schema features. Claude'stool_useaccepts a broader subset, including some$refpatterns (when emitted by the right Zod target) and richerpatternstrings. - How much descriptions count. Claude weighs field descriptions more heavily than GPT-4o does. If your schema has rich
descriptionfields, expect Claude to use them more fully. - Latency. A forced
tool_useadds a small fixed overhead over free-form generation, yet it runs level with GPT-4o'sjson_schemamode in our benchmarks. - Migration cost. Through the OpenAI-compatible endpoint of the codexkey gateway you change one line, the
modelparameter, andresponse_formatkeeps working.
Wrap-up
Claude does not need a json_mode flag, because tool use already gives you something stronger: a typed contract that the API itself enforces. Define the schema once in Pydantic or Zod, force a single tool, and your structured output is as reliable as anything on the market. The OpenAI-compatible shim on the codexkey gateway lets you drop Claude into an existing response_format codebase without touching business logic. Add a retry loop for your own validators and the JSON pipeline stops breaking.