$npx -y skills add softspark/ai-toolkit --skill json-mode-patternsStructured JSON output from Claude: tool-use-as-JSON, schema, parsing, partial recovery. Triggers: JSON mode, structured output, schema validation, JSON parsing.
| 1 | # JSON Mode Patterns |
| 2 | |
| 3 | Claude does not have a dedicated `response_format: json` parameter like some other APIs. The idiomatic way to get guaranteed JSON is **tool use with a forced function call**. This skill documents that pattern plus fallbacks. |
| 4 | |
| 5 | ## Preferred Pattern: Tool-as-Schema |
| 6 | |
| 7 | Define a tool whose input schema IS the JSON shape you want, then force the model to call it. |
| 8 | |
| 9 | ```python |
| 10 | tools = [{ |
| 11 | "name": "record_analysis", |
| 12 | "description": "Return the analysis as structured data", |
| 13 | "input_schema": { |
| 14 | "type": "object", |
| 15 | "properties": { |
| 16 | "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]}, |
| 17 | "confidence": {"type": "number", "minimum": 0, "maximum": 1}, |
| 18 | "themes": {"type": "array", "items": {"type": "string"}} |
| 19 | }, |
| 20 | "required": ["sentiment", "confidence", "themes"] |
| 21 | } |
| 22 | }] |
| 23 | |
| 24 | response = client.messages.create( |
| 25 | model="claude-opus-4-8", |
| 26 | max_tokens=1024, |
| 27 | tools=tools, |
| 28 | tool_choice={"type": "tool", "name": "record_analysis"}, |
| 29 | messages=[{"role": "user", "content": text_to_analyze}] |
| 30 | ) |
| 31 | |
| 32 | # The structured result is in response.content |
| 33 | for block in response.content: |
| 34 | if block.type == "tool_use" and block.name == "record_analysis": |
| 35 | result = block.input # already a Python dict, schema-validated |
| 36 | break |
| 37 | ``` |
| 38 | |
| 39 | Why this wins: |
| 40 | - Schema is enforced at the API level |
| 41 | - No regex or parsing from model text |
| 42 | - Enums, min/max, required fields actually constrain the output |
| 43 | |
| 44 | ## Fallback: Prompted JSON + Strict Parse |
| 45 | |
| 46 | When tool use is unavailable (some SDKs/proxies strip it): |
| 47 | |
| 48 | ```python |
| 49 | response = client.messages.create( |
| 50 | model="claude-opus-4-8", |
| 51 | max_tokens=1024, |
| 52 | system="You return ONLY valid JSON. No prose, no markdown fences.", |
| 53 | messages=[{ |
| 54 | "role": "user", |
| 55 | "content": f"Extract as JSON matching this schema: {schema_str}\n\nInput: {text}" |
| 56 | }] |
| 57 | ) |
| 58 | |
| 59 | import json |
| 60 | try: |
| 61 | result = json.loads(response.content[0].text) |
| 62 | except json.JSONDecodeError: |
| 63 | # Claude sometimes wraps in ```json ... ``` |
| 64 | result = json.loads(strip_markdown_fence(response.content[0].text)) |
| 65 | ``` |
| 66 | |
| 67 | Add a regex fallback that extracts the first `{...}` block if the model added a preface. |
| 68 | |
| 69 | ## Schema Design Rules |
| 70 | |
| 71 | - **Favor enums** over free-form strings when values are known |
| 72 | - **Mark required fields** aggressively — default-optional produces flaky output |
| 73 | - **Use arrays of objects**, not parallel arrays (`[{name, value}]` over `{names: [], values: []}`) |
| 74 | - **Shallow beats nested** — 2 levels of nesting max unless necessary |
| 75 | - **Document each field** in the tool's `description` as well as the schema |
| 76 | |
| 77 | ## Partial Output Recovery |
| 78 | |
| 79 | Model hits `max_tokens` mid-JSON. Strategies: |
| 80 | |
| 81 | 1. **Increase `max_tokens`** if the schema is genuinely large (most common cause). |
| 82 | 2. **Split the schema** — generate one field per call, merge. |
| 83 | 3. **Use streaming** and close unclosed braces if stop reason is `max_tokens`. |
| 84 | |
| 85 | ```python |
| 86 | if response.stop_reason == "max_tokens": |
| 87 | # Either retry with higher budget or gracefully degrade |
| 88 | raise IncompleteOutputError(...) |
| 89 | ``` |
| 90 | |
| 91 | ## Validation After Parse |
| 92 | |
| 93 | Even with tool schema enforcement, business rules aren't enforced by JSON Schema. Add a Pydantic/Zod layer: |
| 94 | |
| 95 | ```python |
| 96 | from pydantic import BaseModel, Field |
| 97 | |
| 98 | class Analysis(BaseModel): |
| 99 | sentiment: Literal["positive", "neutral", "negative"] |
| 100 | confidence: float = Field(ge=0, le=1) |
| 101 | themes: list[str] = Field(min_length=1, max_length=10) |
| 102 | |
| 103 | parsed = Analysis(**result) # raises on violation |
| 104 | ``` |
| 105 | |
| 106 | ## Gotchas |
| 107 | |
| 108 | - **Tool call tokens count toward output budget** — a huge schema eats max_tokens fast |
| 109 | - **`stop_reason == "tool_use"`** is success, not an error |
| 110 | - **Streaming with tool use** requires handling `content_block_delta` events with `input_json_delta` deltas |
| 111 | - **Model picks a different tool** than you expected if `tool_choice` is `"auto"` — always force the specific tool for JSON mode |
| 112 | |
| 113 | ## Related |
| 114 | |
| 115 | - `claude-api` skill — Anthropic SDK essentials |
| 116 | - Anthropic docs: https://docs.claude.com/en/docs/build-with-claude/structured-outputs |