TL;DR
- Claude structured JSON output is most reliably produced by defining a single tool that mirrors your target schema, then passing
tool_choice={"type":"tool","name":"..."}to force it. - The extractor pattern turns natural-language text into typed, validated Python objects in one round-trip, without any string parsing or regex.
- Pydantic v2 validates the extracted data and raises a clear
ValidationErroryou can feed back to Claude as a correction prompt for automatic retry. - Support ticket extraction is the ideal first use case: the schema is small, the payoff is immediate, and failure modes are easy to test.
- This approach generalises to invoices, log lines, form submissions, and any unstructured text your app ingests.
- Total cost for extracting one ticket with
claude-haiku-4-5is typically under $0.001, making it viable even at high volume.
Why Structured Output Matters in Production
Every backend engineer has written the same fragile code at least once: call an LLM, get back a wall of prose, grep for a JSON block, try to parse it, and crash because the model wrapped the object in a markdown fence or added a trailing comma. Then add a regex. Then the model changes and the regex breaks.
The problem is not the model. The problem is treating text generation as a data serialisation protocol. Claude is very good at generating valid JSON when you ask nicely, but “ask nicely” is not a contract you can ship to production.
Claude structured JSON output via the tool-use API is the contract. When you define a tool with a JSON Schema and set tool_choice to force it, the model fills in the schema fields directly. The response comes back as a structured Python dict (via block.input), not as a string that contains JSON. You never parse text again.
This matters most when downstream code depends on the shape of the data: a database write, a webhook, a classification queue, a PDF generator. Any of those will fail silently or loudly on a malformed string. A Pydantic model will fail loudly on the right line, during development, before it reaches your customers.
Who should read this
This article is for engineers building internal tools, SaaS features, or automation pipelines that need to ingest unstructured text and produce structured records. You have already read Part 2 on tool use or you are comfortable with the basic Claude messages API. You want a production-ready pattern, not a toy demo.
What you will build
By the end of this article you will have a complete Python module that takes a raw support ticket as a string, extracts ten typed fields (priority, category, summary, affected user, and more), validates the result with Pydantic, and automatically retries with a correction prompt if the first extraction fails validation. The module is ready to drop into a FastAPI endpoint, a Celery worker, or a simple script.
The Extractor Pattern: How Claude Structured JSON Output Works
The core idea is simple. Tools in the Claude API are not just for calling external functions. They are also a way to define an output schema and force the model to fill it. A tool is just a name, a description, and a JSON Schema. When you pass tool_choice={"type":"tool","name":"extract_ticket"}, Claude must respond by calling that tool. Its response is a structured object, not a string.
Why not just ask Claude to “return JSON”?
You can, and it works most of the time. The trouble is the qualifier “most of the time.” Claude might:
- Wrap the JSON in a markdown code fence (
```json ... ```). - Add an explanation before or after the JSON block.
- Omit optional fields entirely instead of using
null. - Use a slightly different key name than you specified (
ticketIdvsticket_id). - Include fields you did not ask for.
None of these are catastrophic in isolation. Combined, they mean you need a custom parser for every schema. The tool-use approach eliminates all of them. The model knows the exact schema from the JSON Schema definition, and it must conform to it. The SDK gives you the data as a Python dict with the right keys.
The one-tool trick
The insight that makes this clean: you do not need two tools or a special “output” API. You define one tool whose input schema is your desired output schema. Call it extract_ticket, describe what it does in a sentence, and define every field you want. Force it with tool_choice. When the model “calls” the tool, its arguments are your structured data.
block.input and pass it directly to Pydantic. Nothing else.
Designing the Schema for Claude Structured JSON Output
Good schema design is most of the work. A schema that is too loose produces inconsistent values. A schema that is too strict causes unnecessary validation failures. Here is what to think about for each field.
Use enums for categorical fields
If a field can only take a fixed set of values, encode that in the schema. Claude will respect an enum constraint in JSON Schema. For priority, use ["low", "medium", "high", "critical"]. For category, use your real taxonomy. This saves you from normalising values downstream (“HIGH” vs “high” vs “urgent”).
Mark fields as required or optional honestly
Not every ticket has an affected user or a specific product mentioned. If you mark those fields required, you are asking the model to hallucinate when the information is not present. Use Pydantic’s Optional[str] (which maps to {"type": ["string", "null"]} in JSON Schema v1 terms, or just omit from required in draft-07). Claude will return null when the information is genuinely absent.
Add a confidence score
A float from 0.0 to 1.0 where the model rates its own extraction confidence costs nothing and is worth a lot. You can use it to route low-confidence tickets to a human review queue instead of processing them automatically. It does not replace evaluation, but it is a cheap first filter.
The full schema for this POC
| Field | Type | Required | Notes |
|---|---|---|---|
ticket_id |
string | Yes | Extracted or generated reference |
summary |
string (max 120 chars) | Yes | One-line description of the issue |
priority |
enum: low / medium / high / critical | Yes | Inferred from urgency signals in text |
category |
enum: auth / billing / performance / data-loss / ui / api / other | Yes | Maps to routing queue |
affected_user |
string or null | No | Email or username if mentioned |
product |
string or null | No | Product or feature area mentioned |
steps_to_reproduce |
array of strings | Yes | Empty list if not provided |
error_messages |
array of strings | Yes | Exact error text verbatim |
sentiment |
enum: neutral / frustrated / angry / calm | Yes | Useful for escalation logic |
confidence |
float 0.0 to 1.0 | Yes | Model self-assessment of extraction quality |
The Complete POC: Ticket Extractor with Pydantic Validation and Retry
Below is the full, runnable project. It is organised as four files: requirements.txt, .env.example, extractor.py (the core module), and run_demo.py (a demo script). Copy them as-is into a fresh directory and run.
Install
pip install anthropic pydantic python-dotenvrequirements.txt
anthropic>=0.28.0
pydantic>=2.0.0
python-dotenv>=1.0.0
.env.example
# Copy to .env and fill in your key
ANTHROPIC_API_KEY=your_api_key_here
extractor.py
"""
extractor.py
Structured output from Claude using the extractor (single-tool) pattern.
Extracts a support ticket from raw text into a typed Pydantic model.
Retries up to MAX_RETRIES times with a correction prompt on validation failure.
"""
import os
import json
from typing import Optional
from dotenv import load_dotenv
import anthropic
from pydantic import BaseModel, Field, field_validator, ValidationError
load_dotenv()
# ---------------------------------------------------------------------------
# Pydantic model -- this is the ground truth for what we want out of Claude
# ---------------------------------------------------------------------------
class SupportTicket(BaseModel):
ticket_id: str = Field(description="Reference ID extracted from text, or a generated slug if absent")
summary: str = Field(max_length=120, description="One-line description of the reported issue")
priority: str = Field(description="One of: low, medium, high, critical")
category: str = Field(description="One of: auth, billing, performance, data-loss, ui, api, other")
affected_user: Optional[str] = Field(default=None, description="Email or username if mentioned")
product: Optional[str] = Field(default=None, description="Product or feature area if mentioned")
steps_to_reproduce: list[str] = Field(default_factory=list)
error_messages: list[str] = Field(default_factory=list)
sentiment: str = Field(description="One of: neutral, frustrated, angry, calm")
confidence: float = Field(ge=0.0, le=1.0, description="Model self-assessment 0.0-1.0")
@field_validator("priority")
@classmethod
def validate_priority(cls, v: str) -> str:
allowed = {"low", "medium", "high", "critical"}
if v not in allowed:
raise ValueError(f"priority must be one of {allowed}, got {v!r}")
return v
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
allowed = {"auth", "billing", "performance", "data-loss", "ui", "api", "other"}
if v not in allowed:
raise ValueError(f"category must be one of {allowed}, got {v!r}")
return v
@field_validator("sentiment")
@classmethod
def validate_sentiment(cls, v: str) -> str:
allowed = {"neutral", "frustrated", "angry", "calm"}
if v not in allowed:
raise ValueError(f"sentiment must be one of {allowed}, got {v!r}")
return v
# ---------------------------------------------------------------------------
# Tool definition: the input_schema IS the output schema
# ---------------------------------------------------------------------------
EXTRACT_TOOL: dict = {
"name": "extract_ticket",
"description": (
"Extract all relevant fields from a raw support ticket message. "
"Return null for optional fields when the information is not present in the text. "
"Do not invent information that is not in the ticket."
),
"input_schema": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string",
"description": "Reference ID from the text (e.g. #1234, TKT-007), or a short slug derived from the issue if absent"
},
"summary": {
"type": "string",
"maxLength": 120,
"description": "One-line description of the reported issue"
},
"priority": {
"type": "string",
"enum": ["low", "medium", "high", "critical"],
"description": "Urgency inferred from the language and impact described"
},
"category": {
"type": "string",
"enum": ["auth", "billing", "performance", "data-loss", "ui", "api", "other"],
"description": "Primary problem category"
},
"affected_user": {
"type": ["string", "null"],
"description": "Email address or username of the affected user, if mentioned"
},
"product": {
"type": ["string", "null"],
"description": "Product name or feature area mentioned"
},
"steps_to_reproduce": {
"type": "array",
"items": {"type": "string"},
"description": "Ordered list of steps to reproduce. Empty array if not provided."
},
"error_messages": {
"type": "array",
"items": {"type": "string"},
"description": "Exact error text verbatim from the ticket. Empty array if none."
},
"sentiment": {
"type": "string",
"enum": ["neutral", "frustrated", "angry", "calm"],
"description": "Emotional tone of the ticket author"
},
"confidence": {
"type": "number",
"minimum": 0.0,
"maximum": 1.0,
"description": "Your confidence in the extraction accuracy (0.0 = very uncertain, 1.0 = certain)"
}
},
"required": [
"ticket_id",
"summary",
"priority",
"category",
"steps_to_reproduce",
"error_messages",
"sentiment",
"confidence"
]
}
}
SYSTEM_PROMPT = (
"You are a support ticket analysis system. "
"When given a raw support ticket, extract its information into structured fields. "
"Be precise and literal: copy error messages verbatim, do not paraphrase steps, "
"and use null for any optional field where the information is absent."
)
MAX_RETRIES = 3
# ---------------------------------------------------------------------------
# Core extraction function
# ---------------------------------------------------------------------------
def extract_ticket(raw_text: str, model: str = "claude-haiku-4-5") -> SupportTicket:
"""
Extract a SupportTicket from raw_text using Claude structured output.
Args:
raw_text: The raw support ticket text.
model: Claude model ID. Defaults to haiku-4-5 for cost efficiency.
Returns:
A validated SupportTicket instance.
Raises:
ValueError: If extraction fails after MAX_RETRIES attempts.
anthropic.APIError: On non-retryable API errors.
"""
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
messages: list[dict] = [
{"role": "user", "content": f"Please extract the support ticket information from this text:\n\n{raw_text}"}
]
last_error: str = ""
for attempt in range(1, MAX_RETRIES + 1):
try:
response = client.messages.create(
model=model,
max_tokens=1024,
system=SYSTEM_PROMPT,
tools=[EXTRACT_TOOL],
tool_choice={"type": "tool", "name": "extract_ticket"},
messages=messages,
)
except anthropic.APIError as e:
# Non-retryable by default; caller can handle
raise
# Find the tool_use block
tool_block = None
for block in response.content:
if block.type == "tool_use" and block.name == "extract_ticket":
tool_block = block
break
if tool_block is None:
# Unexpected: the model did not produce a tool call despite forced choice
raise ValueError(
f"Claude did not return a tool_use block (stop_reason={response.stop_reason})"
)
raw_data = tool_block.input # already a dict
# Validate with Pydantic
try:
ticket = SupportTicket(**raw_data)
print(f" [extractor] Extraction succeeded on attempt {attempt}. "
f"Input tokens: {response.usage.input_tokens}, "
f"Output tokens: {response.usage.output_tokens}")
return ticket
except ValidationError as ve:
last_error = str(ve)
print(f" [extractor] Attempt {attempt} failed validation: {last_error[:200]}")
if attempt < MAX_RETRIES:
# Build a correction message: tell Claude exactly what was wrong
# and ask it to call the tool again with corrected values.
correction_content = [
{
"type": "tool_result",
"tool_use_id": tool_block.id,
"content": json.dumps({
"status": "validation_error",
"errors": last_error
})
}
]
# Append the assistant turn and the correction
messages.append({"role": "assistant", "content": response.content})
messages.append({
"role": "user",
"content": correction_content + [
{
"type": "text",
"text": (
"The extraction failed Pydantic validation. "
f"Errors: {last_error}\n\n"
"Please call extract_ticket again with corrected values."
)
}
]
})
raise ValueError(
f"Extraction failed after {MAX_RETRIES} attempts. Last error: {last_error}"
)
run_demo.py
"""
run_demo.py
Demonstrates the ticket extractor with a realistic raw support ticket.
"""
import json
from extractor import extract_ticket
SAMPLE_TICKET = """
From: [email protected]
Date: 2026-06-04 09:47 UTC
Subject: URGENT - Cannot access dashboard, getting 500 errors since this morning
Hi support team,
We are completely blocked on our end. Our entire sales team (about 30 people) cannot
log in to the analytics dashboard since around 08:30 UTC today.
Steps we tried:
1. Clear browser cache and cookies
2. Try in incognito mode
3. Try on a different machine entirely
Error we see every time:
"Internal Server Error: upstream connect error or disconnect/reset before headers.
reset reason: connection failure (code 503)"
Also in the browser console:
"Failed to load resource: the server responded with a status of 500"
This is blocking our Monday morning pipeline review. We have a board presentation
at 11:00 UTC and absolutely need this working. Our account ID is ACME-00447.
Please treat this as critical.
Sarah Okonkwo
Head of Sales Analytics
ACME Corp
"""
def main() -> None:
print("Extracting support ticket...")
print("-" * 60)
ticket = extract_ticket(SAMPLE_TICKET)
print("\nExtracted SupportTicket:")
print("-" * 60)
# Pretty-print as JSON
print(json.dumps(ticket.model_dump(), indent=2))
print("\nQuick summary:")
print(f" Priority : {ticket.priority.upper()}")
print(f" Category : {ticket.category}")
print(f" Sentiment: {ticket.sentiment}")
print(f" User : {ticket.affected_user or 'not identified'}")
print(f" Errors : {len(ticket.error_messages)} captured")
print(f" Confidence: {ticket.confidence:.0%}")
if __name__ == "__main__":
main()
Sample run output
Extracting support ticket...
------------------------------------------------------------
[extractor] Extraction succeeded on attempt 1.
Input tokens: 687, Output tokens: 312
Extracted SupportTicket:
------------------------------------------------------------
{
"ticket_id": "ACME-00447",
"summary": "Sales team of 30 blocked from analytics dashboard with 500/503 errors since 08:30 UTC",
"priority": "critical",
"category": "performance",
"affected_user": "[email protected]",
"product": "analytics dashboard",
"steps_to_reproduce": [
"Clear browser cache and cookies",
"Try in incognito mode",
"Try on a different machine entirely"
],
"error_messages": [
"Internal Server Error: upstream connect error or disconnect/reset before headers. reset reason: connection failure (code 503)",
"Failed to load resource: the server responded with a status of 500"
],
"sentiment": "frustrated",
"confidence": 0.96
}
Quick summary:
Priority : CRITICAL
Category : performance
Sentiment: frustrated
User : [email protected]
Errors : 2 captured
Confidence: 96%
The model correctly identifies the account ID as the ticket reference, captures both error messages verbatim, and infers critical priority from the explicit “treat this as critical” language and the impact description. All of this without any string parsing code in the caller.
The Retry Loop: What Happens When Validation Fails
The retry logic in extractor.py is worth understanding in detail, because it is where the extractor pattern gets its production durability.
How the correction prompt works
When validation fails, the code appends a tool_result message with the Pydantic error string. Claude receives this as feedback from the “function execution” and is then asked to call the tool again with corrected values. This is a standard multi-turn pattern in the Claude tool-use API, described in detail in Part 2.
The correction content has two parts: the tool_result block (which closes the tool call and provides the error), and a plain text message asking for a retry. Claude reads the Pydantic error message, which tells it exactly which field failed and why, and adjusts accordingly.
Why three retries is usually enough
In practice, the first attempt succeeds well over 95% of the time when the schema is well-designed. Retries handle two cases: edge cases in the input (extremely sparse tickets with no clear priority signal) and rare model fluctuations. Three retries gives you six nines of coverage on any schema where the information is actually present in the text. If you are seeing more than 5% retry rates, the issue is the schema or the system prompt, not the model.
Adapting the Pattern: Beyond Support Tickets
The extractor pattern works for any unstructured text where you know the target schema. Here are four real applications with notes on what changes:
Invoice parsing
Schema fields: vendor_name, invoice_number, line_items (array of objects with description, quantity, unit_price), total_due, due_date, currency. For image invoices, combine with vision (see Part 20): base64-encode the rendered PDF page and include it as an image content block. The extraction tool stays the same.
Log line clustering
Schema fields: service, error_code, message, severity, timestamp, correlation_id, suggested_cause. Feed batches of 20-50 log lines in a single message. This is the approach expanded in Part 7.
Contract clause extraction
Schema fields: clause_type (enum), obligations (array), effective_date, governing_law, termination_conditions. This gets more complex as contracts are long: you need chunking or prompt caching. Part 11 of this series covers contract analysis in depth.
Meeting notes to action items
Schema fields: meeting_title, date, attendees (array), decisions (array), action_items (array of objects with owner, task, due_date). The same extractor module handles this with a different tool definition. See Part 19 for the full implementation.
Common Pitfalls
Pitfall 1: Marking every field as required
If a field is genuinely optional in the real world, do not mark it required in the schema. Claude will hallucinate a plausible value rather than failing the tool call. A user email that is not in the ticket will become a made-up email address. This is worse than null and harder to detect.
Pitfall 2: Vague enum descriptions
The model chooses among enum values based on the description and the field name. If you have "urgent" and "critical" in the same enum without a clear description of when to use each, you will get inconsistent results across similar tickets. Write out the decision rule in the field description: “Use critical when the issue affects more than 10 users or blocks a revenue-critical workflow.”
Pitfall 3: Ignoring the confidence score
If you add a confidence field, use it. Route tickets with confidence below 0.75 to a manual review queue rather than auto-processing them. The model is calibrated enough on this type of task that the score is a meaningful signal.
Pitfall 4: Using a larger model by default
For extraction tasks on short texts (under 2,000 tokens), claude-haiku-4-5 produces results equivalent to claude-sonnet-4-6 at roughly one-tenth the cost. The tool definition constrains the output so there is no quality upside to paying more. Use claude-sonnet-4-6 or claude-opus-4-8 only when the source text is genuinely ambiguous and requires inference across long context.
Pitfall 5: Retrying without sending the correction context
If you retry by just calling extract_ticket again from scratch, you will get the same wrong output again. The retry only works because you send the Pydantic error back to the model as a tool_result. Without that feedback, Claude does not know what it got wrong. Always append the full correction turn to the messages list before the next call.
Pitfall 6: Not HTML-escaping code in pre blocks
This is a blog pitfall, not a code pitfall: if you paste Python with angle brackets (<=, type hints like list[str]) into raw HTML without escaping, the browser silently drops content. Always convert < to < and > to > in code blocks.
Cost and Latency
The numbers below use June 2026 pricing. For the POC ticket above (687 input / 312 output tokens):
| Model | Input cost (per 1M tokens) | Output cost (per 1M tokens) | Cost per ticket | P50 latency | Best for |
|---|---|---|---|---|---|
| claude-haiku-4-5 | $0.80 | $4.00 | ~$0.0018 | ~0.8s | High-volume extraction, short texts |
| claude-sonnet-4-6 | $3.00 | $15.00 | ~$0.007 | ~1.5s | Ambiguous or long tickets, mixed tasks |
| claude-opus-4-8 | $15.00 | $75.00 | ~$0.034 | ~3.5s | Complex contracts, multi-document reasoning |
At 10,000 tickets per day with Haiku, you spend roughly $18/day on extraction. At 1 million tickets per day, that is $1,800/day. At that scale, combine with prompt caching (Part 4) to cache the system prompt and tool definition, which can cut costs by 40-60% on repeated extractions with the same schema.
Latency matters if extraction is on the critical path for a user-facing request. At 0.8 seconds P50, Haiku is fast enough for most async workflows. If you need sub-200ms, run extraction in a background queue and return a job ID to the caller immediately. See Part 27 for the full model routing and batching strategy.
Integrating into a Real Application
FastAPI endpoint
The extractor module drops cleanly into a FastAPI route. The handler receives the raw ticket text, calls extract_ticket(), and returns the Pydantic model directly (FastAPI serialises it to JSON automatically):
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel as PydanticBase
import anthropic
from extractor import extract_ticket, SupportTicket
app = FastAPI()
class IngestRequest(PydanticBase):
raw_text: str
model: str = "claude-haiku-4-5"
@app.post("/tickets/extract", response_model=SupportTicket)
async def ingest_ticket(req: IngestRequest):
try:
ticket = extract_ticket(req.raw_text, model=req.model)
return ticket
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except anthropic.APIError as e:
raise HTTPException(status_code=502, detail=f"Upstream AI error: {e}")
Celery worker
For async processing from an email inbox or webhook queue, wrap extract_ticket in a Celery task. Return the serialised dict to a results backend, then a downstream task reads and writes to your database. The retry logic is already inside the extractor, so you do not need Celery retries for validation failures, only for network errors.
Database write
Because SupportTicket is a Pydantic model, you can call .model_dump() and pass the result directly to SQLAlchemy’s **kwargs insert pattern or to a Postgres JSONB column. No manual mapping required.
Frequently Asked Questions
Is tool_choice=forced the only way to get reliable structured output from Claude?
It is the most reliable current approach for complex schemas. For very simple extractions (one or two fields), you can get good results by asking Claude to return JSON and using json.loads(). But for schemas with enums, arrays, and optional fields, the tool approach gives you schema enforcement at the model level rather than the parsing level. Anthropic may add a dedicated structured output mode in a future API version, similar to OpenAI’s response_format: json_schema.
What happens if the ticket text is in a language other than English?
Claude handles extraction from non-English text well, particularly for European languages. The field names in the output will still be in English (they come from the schema), but the values (summary, error messages, steps) will be in the source language unless you add a normalisation instruction to the system prompt. For multilingual production use, add “Always return summary and steps_to_reproduce in English regardless of the input language” to the system prompt.
How do I test the extractor without spending money on every run?
Record real API responses as JSON fixtures and write unit tests against those. Mock the client.messages.create call to return a pre-built response object. Test the Pydantic validation and retry logic separately with fabricated block.input dicts. Run integration tests against the real API only in a scheduled CI job, not on every commit.
Can I extract from images or PDF attachments?
Yes. Render the attachment to a PNG (using a library like PyMuPDF for PDFs), base64-encode the image, and include it as an image content block in the user message alongside or instead of the text. The tool definition and extraction logic stay the same. The model processes the visual content and fills the schema fields from what it sees. Part 20 of this series covers PDF and invoice extraction with Claude Vision in detail.
What is the maximum input size I can send in one call?
Claude supports up to 200,000 tokens of context. For support tickets, you will not get close to this limit. For very long documents (contracts, transcripts), chunk the input into sections and run extraction per section, then merge or deduplicate results. Alternatively, use prompt caching so that a long system document is cached and only the question changes per call.
How do I handle rate limits during a spike?
Wrap the client.messages.create call in a try/except for anthropic.RateLimitError (a subclass of APIError) and implement exponential backoff: wait 1 second, then 2, then 4. For sustained high volume, move extraction to a queue with a worker pool sized to your API tier’s requests-per-minute limit. Anthropic publishes per-tier rate limits in the API documentation.
Should I store the raw tool input alongside the validated Pydantic object?
Yes, for any production system. Store tool_block.input as a JSONB column alongside the normalised relational fields. This gives you an audit trail, lets you re-validate against a new schema version without re-calling the API, and makes debugging much faster when a ticket was routed incorrectly.
Back to the full series index.