(json.dumps(data, indent=2))
Enter fullscreen mode Exit fullscreen mode
Output:
{
"name": "SoundCore Mini 3",
"price_usd": 39.99,
"in_stock": true,
"tags": ["black", "red"]
}
Enter fullscreen mode Exit fullscreen mode
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)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.
""",
}],
)
Enter fullscreen mode Exit fullscreen mode
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)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))
Enter fullscreen mode Exit fullscreen mode
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-nodejs-version)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;
Enter fullscreen mode Exit fullscreen mode
## [](#gotchas-worth-knowing)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-cant-reach-raw-apianthropiccom-endraw-)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](https://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.
## [](#tldr)TL;DR
- Define a JSON Schema for the structure you want
- Pass it as the `input_schema` of a "fake" tool
- Force Claude to call that tool with `tool_choice={"type": "tool", "name": "..."}`
- Read `tool_use.input` for 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._