Understand Message Blocks
What a Content Block Is
A content block is one typed piece of a message. Instead of representing a message as a single string, the Claude API represents it as a list of blocks. Each block has a type that says what it contains.
"content": [
{"type": "thinking", "thinking": "", "signature": "EuYB..."},
{"type": "text", "text": "Django, Flask, and FastAPI are popular choices."}
]
You have already met blocks in Lessons 11, 14, and 15, where you filtered message.content for text blocks. This lesson explains the full model: which block types exist, what each contains, why the API is designed this way, and how to handle blocks correctly in both responses and requests.
Why Messages Are Made of Blocks
A plain string can only hold text. Real interactions with Claude contain much more:
| Content | Example |
|---|---|
| Text | The answer itself |
| Reasoning | Claude's thinking before the answer |
| Images and documents | A photo or PDF you send for analysis |
| Tool calls | Claude asking your program to run a function |
| Tool results | Your program returning that function's output |
| Citations | Text linked to the source passages it came from |
Blocks represent all of these in one consistent structure:
- Mixed content fits naturally. A single user message can contain an image followed by a question. A single reply can contain reasoning, then text, then a tool call.
- Order is preserved. Blocks appear in the order they were produced or sent.
- Code can branch on
type. Your program handles each kind of content appropriately, for example showing text, running tools, and hiding thinking. - New features add new block types without changing the structure of existing ones. Code that filters by type keeps working.
Blocks in Responses
These are the block types you can find in message.content:
| Type | Key fields | When it appears | Covered in |
|---|---|---|---|
text | text, citations | Every normal answer | This lesson, Lesson 157 |
thinking | thinking, signature | When the model reasons before or between outputs | This lesson, Lessons 151–153 |
redacted_thinking | data | When part of the reasoning is returned in encrypted form | Lesson 153 |
tool_use | id, name, input | When Claude calls a tool you defined | Lessons 81–90 |
server_tool_use | id, name, input | When Claude uses a server-side tool, such as web search | Lesson 155 |
Server tool result blocks (for example, web_search_tool_result) | Tool-specific | Results of server-side tools | Lesson 155 |
In a basic request with no tools, you will see text and thinking blocks.
Text Blocks
block.type # "text"
block.text # The text content
block.citations # None, or a list of citations when citations are enabled
A response can contain more than one text block. With citations enabled, for example, the answer is split into segments, each with its own sources. Always combine all text blocks rather than taking the first one (Lesson 15).
Thinking Blocks
block.type # "thinking"
block.thinking # The reasoning text; empty by default
block.signature # An opaque value used to verify the block
What they are: current Claude models use adaptive thinking. Before answering, and sometimes between steps such as tool calls, the model may reason through the problem. On Claude Opus 5.5 and Fable 5.1, thinking is always on.
Why thinking is usually empty: by default, the reasoning text is omitted from the response. The block is present, but block.thinking is an empty string. You can request a summarized version of the reasoning:
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
thinking={"type": "adaptive", "display": "summarized"},
messages=[{"role": "user", "content": "Is 391 a prime number?"}],
)
for block in message.content:
if block.type == "thinking":
print("THINKING:", block.thinking)
elif block.type == "text":
print("ANSWER:", block.text)
Example output:
THINKING: I check divisibility by small primes up to the square root of 391, which is about 19.8. 391 = 17 × 23, so it is not prime.
ANSWER: No. 391 is not prime: it equals 17 × 23.
What the signature is for: it lets the API verify that a thinking block was produced by the model and has not been altered. This matters when you send blocks back in later requests, as in tool-use loops.
Billing: you are billed for the full thinking the model performed, which is reported in usage.output_tokens, not for the shorter summary you see.
Warning: Thinking content is the model's working process, not a polished answer. Do not show it to end users by default. If you display it, label it clearly as reasoning.
Tool Use Blocks (Preview)
When you give Claude tools (Lessons 81–90), a response can contain:
block.type # "tool_use"
block.id # Unique ID of this tool call, e.g. "toolu_01A..."
block.name # The tool to run, e.g. "get_weather"
block.input # The arguments, as a dict: {"city": "Chennai"}
The response's stop_reason is then "tool_use". Your program runs the tool and sends the result back in a tool_result block. That loop is covered in detail later. For now, recognize that a response's content can be an instruction for your code, not only text for a user.
Handling Every Block Type
Because message.content can contain different types, robust code handles each type explicitly. Python's match statement (available since Python 3.10, which the SDK requires) expresses this clearly:
for block in message.content:
match block.type:
case "text":
print(block.text)
case "thinking":
pass # Usually not shown to users
case "redacted_thinking":
pass # Encrypted reasoning; nothing to display
case "tool_use":
print(f"[Tool requested: {block.name} with {block.input}]")
case _:
print(f"[Unhandled block type: {block.type}]")
How it works:
match block.type:compares the block's type string against eachcase.- Each
casehandles one type. case _:is the wildcard. It matches anything not handled above. That makes the code forward-compatible: when a future feature introduces a new block type, your program reports it instead of crashing or silently ignoring it.
You can also check types with isinstance and the classes in anthropic.types:
from anthropic.types import TextBlock, ThinkingBlock
for block in message.content:
if isinstance(block, TextBlock):
print(block.text)
elif isinstance(block, ThinkingBlock):
pass
Both approaches work. Checking block.type is shorter and needs no extra imports. isinstance lets type checkers know the block's exact class inside each branch, which improves editor autocomplete.
Blocks in Requests
Blocks are not only for responses. The content you send is also made of blocks.
The String Shorthand
So far, every user message has used a string:
{"role": "user", "content": "What is Python?"}
This is shorthand for a single text block. The API treats it exactly the same as:
{"role": "user", "content": [{"type": "text", "text": "What is Python?"}]}
Use the string form for plain text, and switch to the list form when a message needs several blocks or non-text content.
Several Blocks in One Message
messages = [{
"role": "user",
"content": [
{"type": "text", "text": "Here is a function:\n\ndef add(a, b): return a + b"},
{"type": "text", "text": "Add a docstring and type hints."},
],
}]
Separate blocks are useful when parts of a message come from different sources, such as a fixed instruction and variable data. They are also needed for features that apply settings to specific blocks, such as prompt caching (Lessons 121–130).
Images and Documents (Preview)
Non-text inputs are sent as their own blocks:
messages = [{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "url", "url": "https://example.com/chart.png"},
},
{"type": "text", "text": "Summarize the trend in this chart."},
],
}]
The image block comes first and the question second, mirroring how a person would show a picture and then ask about it. Lessons 111–120 cover images, PDFs, and files in depth.
Request Block Types
| Type | Sent in | Purpose | Covered in |
|---|---|---|---|
text | user, assistant | Plain text | This lesson |
image | user | An image, by URL, base64 data, or file ID | Lessons 111–113 |
document | user | A PDF or text document | Lessons 114–118 |
tool_result | user | The output of a tool Claude asked you to run | Lesson 87 |
Blocks from a previous response (thinking, tool_use, …) | assistant | Sending Claude's earlier turn back in a conversation | Lessons 24, 88 |
Sending Response Blocks Back
In multi-turn conversations, you send Claude's previous replies back as assistant messages. The safest way is to send the content blocks exactly as you received them:
history = [{"role": "user", "content": "Is 391 a prime number?"}]
reply = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
messages=history,
)
# Append Claude's reply exactly as returned: all blocks, unchanged
history.append({"role": "assistant", "content": reply.content})
history.append({"role": "user", "content": "What are its factors?"})
follow_up = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
messages=history,
)
Why send the blocks unchanged:
- The SDK accepts the response block objects directly, so no conversion is needed.
- Some features require blocks to be returned exactly as received. In tool-use workflows, the API rejects requests in which thinking blocks from the latest assistant turn were edited, reordered, filtered out, or reconstructed. Their
signatureis checked. - Keeping the original blocks preserves everything the model produced, so it has full context on the next turn.
Lesson 24 (Send Conversation History) builds on this.
Practical Example: A Block Explorer
This script sends a question with summarized thinking enabled and prints a labeled breakdown of every block in the response. It is a useful learning and debugging tool.
"""block_explorer.py: show every content block in a Claude response."""
import sys
import textwrap
from anthropic import Anthropic
from anthropic.types import Message
def preview(text: str, width: int = 70) -> str:
"""Shorten long text to a single readable line."""
single_line = " ".join(text.split())
return textwrap.shorten(single_line, width=width, placeholder=" …") or "(empty)"
def explore(message: Message) -> None:
print(f"stop_reason={message.stop_reason} blocks={len(message.content)}\n")
for index, block in enumerate(message.content):
match block.type:
case "text":
print(f"[{index}] text : {preview(block.text)}")
case "thinking":
print(f"[{index}] thinking : {preview(block.thinking)}")
case "redacted_thinking":
print(f"[{index}] redacted : (encrypted reasoning)")
case "tool_use":
print(f"[{index}] tool_use : {block.name}({block.input})")
case _:
print(f"[{index}] {block.type}: (not handled by this explorer)")
if __name__ == "__main__":
question = " ".join(sys.argv[1:]) or "A train leaves at 9:40 and arrives at 13:15. How long is the trip?"
client = Anthropic()
reply = client.messages.create(
model="claude-opus-5-5",
max_tokens=4096,
thinking={"type": "adaptive", "display": "summarized"},
messages=[{"role": "user", "content": question}],
)
explore(reply)
python block_explorer.py
Example output:
stop_reason=end_turn blocks=2
[0] thinking : From 9:40 to 13:40 is 4 hours; 13:15 is 25 minutes earlier, so 3 hours …
[1] text : The trip takes 3 hours and 35 minutes.
How It Works
thinking={"type": "adaptive", "display": "summarized"}keeps adaptive thinking, which is the default, but asks for a summary of the reasoning instead of an emptythinkingfield. Remove it to see the default behavior, where the thinking preview shows(empty).enumerate(message.content)provides each block's index, showing the order of blocks.preview()collapses whitespace with" ".join(text.split())and shortens long text withtextwrap.shorten. Theor "(empty)"makes empty thinking visible instead of printing nothing.match block.typehandles each known type, andcase _reports anything unexpected.
Run it with different questions. Simple questions may produce no thinking block at all, while multi-step problems usually do.
Common Mistakes
Assuming the First Block Is Text
What happens: message.content[0].text raises AttributeError whenever a thinking block comes first.
Fix: filter by block.type.
Assuming There Is Exactly One Text Block
What happens: parts of the answer are lost when the response contains several text blocks.
Fix: join all text blocks.
Treating an Empty thinking Field as an Error
What happens: developers think thinking is broken because block.thinking is "".
Fix: that is the default. Request "display": "summarized" if you need the reasoning text.
Modifying or Dropping Blocks When Sending Them Back
What happens: in tool-use workflows, requests fail with a 400 error saying thinking blocks in the latest assistant message cannot be modified.
Fix: send reply.content back exactly as received.
Showing Thinking to End Users Unintentionally
What happens: users see internal reasoning mixed with the answer.
Fix: display only text blocks by default.
Not Handling Unknown Block Types
What happens: code crashes, or silently loses content, when a new feature introduces a new block type.
Fix: include a default branch (case _ or else) that logs unexpected types.
Best Practices
- Always branch on
block.type, never on position. - Combine all text blocks when you need the answer as a string.
- Hide thinking by default. Request summarized thinking only when you have a real use for it, such as debugging or transparent reasoning displays.
- Return assistant content unchanged when continuing a conversation.
- Handle unknown types gracefully with a default branch, so your code is ready for new features.
- Use the string shorthand for simple text messages, and lists of blocks when content is mixed.
Exercise
- Run
block_explorer.pywith three questions: a greeting, a factual question, and a multi-step math problem. When do thinking blocks appear? - Remove
thinking={...}from the request and run it again. How does the thinking preview change? - Send a user message as a list of two text blocks: one containing a short Python function, and one asking for a review. Confirm that Claude treats them as one message.
- Build a two-turn conversation using
reply.contentas the assistant message, as shown in Sending Response Blocks Back. Print the block types of both replies.
- Simple greetings often produce no thinking block. Multi-step problems usually start with one. The model decides adaptively.
- The thinking block, when present, shows
(empty), because the thinking text is omitted by default. - Example:
reply = client.messages.create(
model="claude-opus-5-5",
max_tokens=2048,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "def area(r): return 3.14 * r * r"},
{"type": "text", "text": "Review this function and suggest improvements."},
],
}],
)
print("".join(block.text for block in reply.content if block.type == "text"))
Claude reviews the function, because both blocks belong to the same user turn.
- Each reply prints its own list of block types, such as
['thinking', 'text']or['text']. The second request succeeds because the first reply's blocks were sent back unchanged.