Parsing Model Output
An LLM reply is not a plain string. It is a structured response with content blocks and a stop reason that tells you why the model stopped. Reading it correctly is what separates a robust agent from one that crashes on the first tool call or truncated answer.
Goal
By the end of this topic, you should be able to:
- Read the response object instead of assuming it is a string.
- Handle every
stop_reasonthe model can return. - Separate text blocks from tool-call blocks.
- Get structured JSON you can trust, instead of parsing free text.
Learning Path
Part 1: The Response Is Not a String
The most common beginner mistake is treating the reply like a string. It is an object whose content is a list of blocks, each with a type:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "What is 2 + 2?"}],
)
# response.content is a list, e.g. [TextBlock(type="text", text="2 + 2 is 4.")]
for block in response.content:
if block.type == "text":
print(block.text)
Always check block.type before reading a field. A reply can contain several blocks — text, a thinking block, one or more tool calls — and they are not interchangeable.
The safe pattern for "just give me the text":
text = next((b.text for b in response.content if b.type == "text"), "")
Other providers
The shapes differ — OpenAI returns response.choices[0].message.content (a string) plus a separate tool_calls list, while Claude interleaves everything in one content block list. Either way the lesson holds: read the documented structure; do not assume a bare string.
Part 2: Stop Reasons
Every response carries a stop_reason telling you why generation ended. Ignoring it is how agents silently break.
stop_reason |
Meaning | What to do |
|---|---|---|
end_turn |
The model finished naturally. | Use the reply as the final answer. |
max_tokens |
It hit your max_tokens cap. |
The reply is truncated — raise the cap or stream. |
tool_use |
It wants to call a tool. | Run the tool and continue the loop. |
stop_sequence |
It hit a custom stop sequence. | Handle per your design. |
refusal |
It declined for safety reasons. | Surface it; do not retry the same prompt. |
pause_turn |
A long server-side turn paused. | Re-send to let it resume. |
OpenAI equivalent
OpenAI calls this finish_reason, on response.choices[0]: stop (≈ end_turn), length (≈ max_tokens), tool_calls (≈ tool_use), and content_filter. The same lesson applies — check it before trusting the reply as final.
The check that prevents the most bugs:
if response.stop_reason == "max_tokens":
# The answer is incomplete. Don't treat it as final.
raise ValueError("Response was truncated — increase max_tokens.")
Treating a max_tokens-truncated reply as a finished answer is a classic silent failure: the output looks like a normal answer but is cut off. The stop_reason is the only reliable signal.
Part 3: Text vs Tool Calls
When you give the model tools, a single reply can contain both an explanation (text block) and one or more requests to act (tool-use blocks). Parsing means separating them:
texts = [b for b in response.content if b.type == "text"]
tool_uses = [b for b in response.content if b.type == "tool_use"]
for call in tool_uses:
print(call.name) # which tool
print(call.input) # arguments, already parsed into a dict
print(call.id) # id you echo back in the tool_result
Two things to get right:
call.inputis already a parsed object (a dict), not a JSON string. Use it directly.- When you do see serialized tool JSON, parse it with
json.loads— never match it as a raw string. Escaping (Unicode, slashes) can vary, and raw-string matching breaks silently.
Feeding tool results back into the loop is the subject of Building an agent loop from scratch; tool design is Stage 05. This page is only about reading the blocks.
Part 4: Structured Output
Often you do not want prose — you want a specific JSON shape your code can rely on. Parsing free text with regexes is fragile. Instead, constrain the output to a schema.
The cleanest approach validates the reply against a model you define:
from pydantic import BaseModel
class Contact(BaseModel):
name: str
email: str
wants_demo: bool
response = client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content":
"Extract: Jane Doe (jane@co.com) asked for a demo."}],
output_format=Contact,
)
contact = response.parsed_output # a validated Contact instance
print(contact.name, contact.wants_demo)
If you do not use a schema library, you can request a raw JSON schema directly and the first text block is guaranteed to be valid JSON:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "Extract the name and email."}],
output_config={"format": {"type": "json_schema", "schema": {
"type": "object",
"properties": {"name": {"type": "string"}, "email": {"type": "string"}},
"required": ["name", "email"],
"additionalProperties": False,
}}},
)
import json
data = json.loads(next(b.text for b in response.content if b.type == "text"))
OpenAI equivalent
from pydantic import BaseModel
from openai import OpenAI
class Contact(BaseModel):
name: str
email: str
wants_demo: bool
client = OpenAI()
completion = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{"role": "user", "content":
"Extract: Jane Doe (jane@co.com) asked for a demo."}],
response_format=Contact,
)
contact = completion.choices[0].message.parsed # a validated Contact instance
| Approach | When to use |
|---|---|
messages.parse() with a schema class |
You want a validated object back. Recommended. |
output_config.format with a raw JSON schema |
You are not using a schema library. |
Strict tool use ("strict": True on a tool) |
The structure is the tool's arguments. |
Prompt-level techniques for asking for structure are Stage 03; this page is about the API-level guarantees that make the output reliable.
Structured output is not refusal-proof
If the model refuses (stop_reason == "refusal") or truncates (max_tokens), the output may not match the schema. Check stop_reason before trusting parsed data.
Practice
Exercise 1: Read Every Block
Send a prompt with tools attached and print block.type for every block in response.content. Note when you see both a text block and a tool-use block in one reply.
Exercise 2: Catch the Truncation
Call the model with max_tokens=10 on a question that needs a long answer. Confirm stop_reason == "max_tokens" and that your code refuses to treat the reply as final.
Exercise 3: Extract to a Schema
Use messages.parse() to extract {title, author, year} from a sentence describing a book. Print the validated object.
Mini Project
Build a small "resume field extractor":
- takes a block of resume text
- uses structured output to return
{name, email, years_experience, skills: [...]} - checks
stop_reasonand reports cleanly if the reply was truncated or refused - prints the validated object
The goal is an extractor that never crashes on a malformed reply — because it reads the structure instead of guessing.
Exit Criteria
You understand this topic when you can:
- Read
response.contentas a list of typed blocks. - Name the main
stop_reasonvalues and handlemax_tokensandrefusal. - Separate text blocks from tool-use blocks and read
call.inputas a dict. - Get schema-validated JSON instead of parsing free text.