Return Tool Results
What a Tool Result Is
After your code runs a tool (Lesson 86), Claude needs to know what happened. You tell it by sending a tool_result content block in the next user message:
{
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9",
"content": "31°C, humid, light rain expected at 5 PM",
}
| Field | Required | Meaning |
|---|---|---|
type | Yes | Always "tool_result" |
tool_use_id | Yes | The id of the tool_use block this result answers |
content | Usually | The result: a string, or a list of content blocks |
is_error | Optional | True when the tool failed; content then explains the problem |
The tool_use_id is what connects a result to its call. Claude may have requested several tools, and each result must say which call it belongs to.
The Message Structure
A complete tool round trip adds two messages to the conversation:
messages = [
{"role": "user", "content": "Is it a good day for a walk in Chennai?"},
# 1. Claude's response, exactly as received (may include thinking, text, and tool_use blocks)
{"role": "assistant", "content": response.content},
# 2. A user message containing the tool results
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01A0...", "content": "31°C, humid, light rain at 5 PM"},
]},
]
Then you send the whole list in the next request, with the same tools (Lesson 88).
Formatting Rules
The API enforces strict rules for tool results. Breaking them causes 400 errors.
| Rule | Explanation |
|---|---|
| Results come immediately after the call | The user message with the results must directly follow the assistant message that contains the tool_use blocks. No other messages in between. |
| Every call gets a result | Each tool_use block needs exactly one tool_result with its id. |
| All results go in one message | When Claude made several calls, return all their results together in a single user message. |
| Results come first | In that user message, all tool_result blocks must come before any text blocks. |
| The assistant message is unchanged | Send response.content back exactly as received, including any thinking blocks (Lesson 16). |
All Results in One Message
According to the parallel tool use documentation, sending each result in a separate user message is the most common formatting mistake. It also "teaches" Claude to avoid parallel calls in later turns.
# Wrong: one user message per result
{"role": "user", "content": [result_1]},
{"role": "user", "content": [result_2]},
# Correct: one user message with all results
{"role": "user", "content": [result_1, result_2]},
Text After Results
You may add text after the results in the same message, for example new information from the user:
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_01...", "content": "31°C, light rain at 5 PM"},
{"type": "text", "text": "Also, I prefer walking in the morning."},
]}
Putting text before a tool_result block causes a 400 error.
What to Put in content
A String
The simplest and most common form:
{"type": "tool_result", "tool_use_id": call_id, "content": "31°C, humid, light rain expected at 5 PM"}
Structured Data as JSON Text
For structured results, send compact JSON as a string (Lesson 86). Claude reads JSON well:
{"type": "tool_result", "tool_use_id": call_id,
"content": '[{"slug": "django-101", "title": "Django for Beginners", "weeks": 6}]'}
A List of Content Blocks
content can also be a list of blocks, such as several text blocks, or images, for example a chart generated by a tool or a screenshot (images are covered in Lessons 111–113):
{"type": "tool_result", "tool_use_id": call_id, "content": [
{"type": "text", "text": "Sales chart for October:"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": chart_base64}},
]}
Writing Useful Results
| Guideline | Why |
|---|---|
| Include only what Claude needs | Results become input tokens on every following request |
| Use stable identifiers (slugs, IDs) | Claude can refer to them in later tool calls |
| Make empty results explicit | "No courses found for 'Rust'." is clearer than [] |
| Note truncation | Tell Claude when a result was shortened, so it does not assume it saw everything |
Error Results
When a tool fails, still send a result, with is_error: True and an explanation:
{
"type": "tool_result",
"tool_use_id": call_id,
"is_error": True,
"content": "No weather data for 'Mumbai'. Supported cities: Chennai, Bengaluru.",
}
Claude reads the error and adapts: it may retry with corrected input, try a different tool, or explain the problem to the user. Lesson 90 covers error handling in depth.
Calls You Choose Not to Run
Sometimes you skip a call, for example when an earlier call in a sequence failed. According to the documentation, you should still return a tool_result for it, with is_error: True and a brief explanation:
{"type": "tool_result", "tool_use_id": "toolu_02", "is_error": True,
"content": "Not executed: the preceding create_booking call failed."}
A missing result is a formatting error. An explicit "not executed" result keeps the conversation valid and tells Claude what happened.
Practical Example: Building and Checking Tool Results
This module builds the tool-result message for a response, using run_tool() from Lesson 86, and includes a checker that verifies the formatting rules before a request is sent. Catching formatting mistakes locally is much easier than debugging 400 errors.
"""tool_results.py: build tool_result messages and check them against the formatting rules."""
from types import SimpleNamespace
from demo_tools import REGISTRY
from tools_lib import ToolContext, run_tool
def build_results_message(response, ctx: ToolContext) -> dict:
"""Run every tool call in a response and return ONE user message with all results."""
results = []
for block in response.content:
if block.type != "tool_use":
continue
outcome = run_tool(REGISTRY, block.name, dict(block.input or {}), ctx)
result = {"type": "tool_result", "tool_use_id": block.id, "content": outcome.content}
if outcome.is_error:
result["is_error"] = True
results.append(result)
return {"role": "user", "content": results}
def field(block, name):
return block.get(name) if isinstance(block, dict) else getattr(block, name, None)
def check_results(assistant_content, results_message: dict) -> list[str]:
"""Return a list of formatting problems; an empty list means the message is valid."""
problems = []
call_ids = [field(b, "id") for b in assistant_content if field(b, "type") == "tool_use"]
blocks = results_message["content"]
result_ids = [b["tool_use_id"] for b in blocks if b.get("type") == "tool_result"]
if results_message["role"] != "user":
problems.append("tool results must be in a user message")
for missing in set(call_ids) - set(result_ids):
problems.append(f"no result for call {missing}")
for extra in set(result_ids) - set(call_ids):
problems.append(f"result for unknown call {extra}")
if len(result_ids) != len(set(result_ids)):
problems.append("more than one result for the same call")
seen_text = False
for block in blocks:
if block.get("type") == "text":
seen_text = True
elif block.get("type") == "tool_result" and seen_text:
problems.append("a tool_result appears after text; results must come first")
break
return problems
if __name__ == "__main__":
response = SimpleNamespace(content=[
SimpleNamespace(type="text", text="Let me check both."),
SimpleNamespace(type="tool_use", id="toolu_01", name="get_weather", input={"city": "Chennai"}),
SimpleNamespace(type="tool_use", id="toolu_02", name="get_weather", input={"city": "Mumbai"}),
])
message = build_results_message(response, ToolContext(user_id="meena"))
for block in message["content"]:
print(block)
print("problems:", check_results(response.content, message) or "none")
broken = {"role": "user", "content": [
{"type": "text", "text": "Here you go:"},
{"type": "tool_result", "tool_use_id": "toolu_01", "content": "31°C"},
]}
print("broken message problems:", check_results(response.content, broken))
Output:
{'type': 'tool_result', 'tool_use_id': 'toolu_01', 'content': '31°C, humid, light rain expected at 5 PM'}
{'type': 'tool_result', 'tool_use_id': 'toolu_02', 'content': "No weather data for 'Mumbai'. Supported cities: Chennai, Bengaluru.", 'is_error': True}
problems: none
broken message problems: ['no result for call toolu_02', 'a tool_result appears after text; results must come first']
How It Works
build_results_message()runs every tool call in the response withrun_tool(), and collects all results into one user message. That satisfies the "every call gets a result" and "all results in one message" rules by construction.is_erroris added only for failures. The Mumbai call fails with a helpful message, and still produces a result for its ID.field()reads fields from SDK block objects or dictionaries, so the checker works on live responses and stored histories (Lesson 41).check_results()verifies the rules:- the message has the
userrole; - every call ID has a result, and no result refers to an unknown call, using set differences;
- no call has two results;
- no
tool_resultblock comes after a text block.
- the message has the
- The broken message fails two checks:
toolu_02has no result, and the text block comes before a result. Running the checker in tests or during development catches these mistakes before the API rejects the request.
Common Mistakes
Missing Results for Some Calls
What happens: a 400 error about tool_use IDs without corresponding results.
Fix: return a result for every call, including error results for calls that failed or were skipped.
Separate Messages for Each Result
What happens: malformed conversations, and fewer parallel tool calls in later turns.
Fix: put all results in one user message.
Text Before Results
What happens: a 400 error.
Fix: place all tool_result blocks first; text may follow them.
Wrong or Reused IDs
What happens: results do not match their calls, and requests fail.
Fix: copy tool_use_id from each call's id, exactly.
Raising Exceptions Instead of Returning Errors
What happens: the conversation stops with an unanswered call.
Fix: return is_error: True results with clear explanations.
Best Practices
- Return exactly one result per call, matched by
tool_use_id. - Put all results in one user message, before any text.
- Send the assistant's tool-use message back unchanged.
- Use strings or compact JSON for most results, and content blocks for images.
- Mark failures with
is_error, with explanations that help Claude recover. - Return "not executed" errors for calls you skip.
- Check formatting in tests before sending.
Exercise
- Build a fake response with three tool calls (two valid, one for an unknown tool), and print the resulting message.
- Add a check to
check_results()that reports when atool_resulthas emptycontent. - Modify
build_results_message()to accept optional user text and append it after the results. - Explain why the documentation recommends returning a "not executed" error result instead of leaving out a call you chose not to run.
Step 3:
def build_results_message(response, ctx: ToolContext, user_text: str | None = None) -> dict:
results = []
for block in response.content:
if block.type != "tool_use":
continue
outcome = run_tool(REGISTRY, block.name, dict(block.input or {}), ctx)
result = {"type": "tool_result", "tool_use_id": block.id, "content": outcome.content}
if outcome.is_error:
result["is_error"] = True
results.append(result)
if user_text:
results.append({"type": "text", "text": user_text}) # After all results
return {"role": "user", "content": results}
Step 4: the API requires a result for every tool_use block, so leaving one out makes the request invalid. Beyond that, an explicit result tells Claude what happened, for example that a step was skipped because an earlier step failed. Claude can then decide what to do next, such as retrying, choosing another approach, or explaining the situation to the user, instead of working with a gap in its information.