If you've ever tried to get structured data out of an LLM with a prompt like "Please respond in valid JSON with the following fields...", you already know the story. It works 95% of the time. The other 5% it returns prose, fenced markdown, a trailing apology, or — my personal favorite — { "name": "Alice", "age": 30, } with a trailing comma that explodes your parser at 3 AM.
This guide walks through a technique that makes Claude return JSON that conforms to your schema on every single call, with no regex parsing and no retry loops. It works because we're not really asking Claude for JSON — we're tricking it into a code path that produces JSON as a side effect.
About this guide. I'm one of the maintainers of claudeapi.com, a third-party Claude API gateway. All code below targets the official Anthropic endpoint (
api.anthropic.com); a comparison of alternative gateways for developers in restricted regions appears near the end. Skip it if you're on the official endpoint and it works fine for you.
The trick: hijack tool use
Claude's tool_use feature was designed so the model can call your functions. Under the hood, when you define a tool, you give Claude a JSON Schema for its arguments. The model is fine-tuned to produce arguments that match that schema, validated server-side before being returned to you.
So the trick is: define a fake tool whose input schema is the structure you want, then force Claude to call it. You never actually execute the "tool" — you just read its arguments. That's your guaranteed JSON.
Example 1: basic product extraction
import anthropic
import json
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
product_schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"price_usd": {"type": "number"},
"in_stock": {"type": "boolean"},
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["name", "price_usd", "in_stock"],
}
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=1024,
tools=[{
"name": "extract_product",
"description": "Extract structured product information from text.",
"input_schema": product_schema,
}],
tool_choice={"type": "tool", "name": "extract_product"}, # forces the call
messages=[{
"role": "user",
"content": "The new SoundCore Mini 3 is $39.99, currently in stock, available in black and red.",
}],
)
# The structured data is in the tool_use block's input field
for block in response.content:
if block.type == "tool_use":
data = block.input
print(json.dumps(data, indent=2))
Output:
{
"name": "SoundCore Mini 3",
"price_usd": 39.99,
"in_stock": true,
"tags": ["black", "red"]
}
The key line is tool_choice={"type": "tool", "name": "extract_product"}. Without it, Claude might decide it doesn't need a tool and just chat back. With it, the model must emit a tool_use block matching your schema.
Example 2: nested structures with enums
Real-world schemas have nested objects, arrays of objects, and constrained values. JSON Schema handles all of it.
order_schema = {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"status": {
"type": "string",
"enum": ["pending", "shipped", "delivered", "cancelled"],
},
"customer": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string", "format": "email"},
},
"required": ["name", "email"],
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
"unit_price": {"type": "number"},
},
"required": ["sku", "quantity", "unit_price"],
},
},
},
"required": ["order_id", "status", "customer", "items"],
}
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=2048,
tools=[{
"name": "parse_order",
"description": "Parse an order from a free-text customer email.",
"input_schema": order_schema,
}],
tool_choice={"type": "tool", "name": "parse_order"},
messages=[{
"role": "user",
"content": """
Hi, I'm Jane Doe (jane@example.com). Following up on order #A-7821 —
it shows as shipped but tracking hasn't updated in 4 days. The order
was 2x SKU-BLK-001 at $19.99 each and 1x SKU-RED-042 at $34.50.
""",
}],
)
The enum constraint guarantees status will be one of the four values you specified — Claude can't return "in_transit" even if it would be more semantically accurate. Treat enums as a contract.
Example 3: resume parsing (the messy real world)
resume_schema = {
"type": "object",
"properties": {
"full_name": {"type": "string"},
"email": {"type": ["string", "null"]},
"years_experience": {"type": ["integer", "null"]},
"skills": {"type": "array", "items": {"type": "string"}},
"education": {
"type": "array",
"items": {
"type": "object",
"properties": {
"institution": {"type": "string"},
"degree": {"type": "string"},
"year": {"type": ["integer", "null"]},
},
},
},
},
"required": ["full_name", "skills", "education"],
}
resume_text = """
JOHN SMITH
johnsmith@email.com
EXPERIENCE
Backend Engineer, Acme Corp (2019 - present)
Junior Developer, BetaStart (2016 - 2019)
SKILLS: Python, Go, PostgreSQL, Redis, Kubernetes
EDUCATION
B.S. Computer Science, State University, 2016
"""
response = client.messages.create(
model="claude-opus-4-7",
max_tokens=2048,
tools=[{
"name": "extract_resume",
"description": "Extract structured fields from a free-form resume.",
"input_schema": resume_schema,
}],
tool_choice={"type": "tool", "name": "extract_resume"},
messages=[{"role": "user", "content": resume_text}],
)
data = next(b.input for b in response.content if b.type == "tool_use")
print(json.dumps(data, indent=2))
Note the ["string", "null"] union type — that's how you tell Claude "this field might not be present in the source." It's much more reliable than asking Claude to "omit if missing."
TypeScript / Node.js version
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Product {
name: string;
price_usd: number;
in_stock: boolean;
tags: string[];
}
const productSchema = {
type: "object",
properties: {
name: { type: "string" },
price_usd: { type: "number" },
in_stock: { type: "boolean" },
tags: { type: "array", items: { type: "string" } },
},
required: ["name", "price_usd", "in_stock"],
} as const;
const response = await client.messages.create({
model: "claude-opus-4-7",
max_tokens: 1024,
tools: [{
name: "extract_product",
description: "Extract structured product information from text.",
input_schema: productSchema,
}],
tool_choice: { type: "tool", name: "extract_product" },
messages: [{
role: "user",
content: "The new SoundCore Mini 3 is $39.99, currently in stock...",
}],
});
const toolUse = response.content.find((b) => b.type === "tool_use");
const product = toolUse?.input as Product;
Gotchas worth knowing
max_tokens truncation. If Claude's output gets cut off mid-JSON, you get stop_reason: "max_tokens" and a partial structure. Bump max_tokens higher than you think you need; large schemas with arrays can blow past 2048 quickly.
Field name mismatch. Claude follows your schema field names exactly. If your downstream code expects price but your schema says price_usd, you'll silently get None in production. The schema is the contract — write it once, share it both directions.
Optional fields. JSON Schema's "optional" is the absence of a field from required. To say "this field might exist but might be null," use "type": ["string", "null"]. Be explicit; Claude will respect either form, but mixing them in the same schema is a recipe for surprises.
Don't try to validate semantics in the schema. A regex pattern will be respected, but complex constraints (e.g. "year must be after 1900 and before 2030") are better enforced after the fact. Claude usually honors them, but JSON Schema's validation surface is large and not all of it is equally reliable.
When you can't reach api.anthropic.com
This is the part most "guaranteed JSON" guides skip. Claude's API isn't available in every region. If you're hitting connection issues or 403s from your deployment region, you have a few options:
| Option | Best for | Tradeoff |
|---|---|---|
| Self-hosted proxy (Cloudflare Workers, Vercel Edge) | DIY developers in mildly restricted regions | You own the infra; latency depends on your edge config |
| AWS Bedrock | Teams already on AWS, enterprise compliance | Different SDK, different model IDs, no day-one access to new releases |
| GCP Vertex AI | Teams on GCP, EU data residency | Same as Bedrock — SDK divergence |
| claudeapi.com | China / SEA / regions with payment friction | Third-party gateway; pay in USD via card or local methods. Disclosure: I'm affiliated. |
| OpenRouter | Multi-provider testing | Adds a hop; pricing markup varies by model |
All of these expose an Anthropic-compatible interface, so the code above runs unchanged — only the base_url differs. If api.anthropic.com works for you, stay there; the official endpoint will always have the lowest latency and the fewest hops.
TL;DR
- Define a JSON Schema for the structure you want
- Pass it as the
input_schemaof a "fake" tool - Force Claude to call that tool with
tool_choice={"type": "tool", "name": "..."} - Read
tool_use.inputfor guaranteed-valid JSON — no parsing, no retries
That's the whole pattern. Once it clicks, you'll stop reaching for regex on LLM output.
If this was useful, the same pattern extends naturally to chained tool calls and agent workflows — happy to dig into either if there's interest in the comments.
Top comments (0)