- Published on
Multi-Turn Conversations with Tools
- Authors

- Name
- Vuk Dukic
Founder, AI/ML Engineer

Multi-Turn Conversations with Tools
When building applications with multiple tools, you need to handle scenarios where Claude might need to call several tools in sequence to answer a single user question. For example, if a user asks "What day is 103 days from today?", Claude needs to first get the current date, then add 103 days to it.
This creates a multi-turn conversation pattern where Claude makes multiple tool requests before providing a final answer. Your application needs to handle this automatically.
The Problem: Sequential Tool Dependencies
Many real-world questions require multiple steps to answer:
- "What day is 103 days from today?" → Get current date, then add duration
- "How much did we spend on marketing last quarter?" → Get current date, calculate quarter dates, query expenses
- "What's the weather forecast for my next meeting?" → Get calendar, extract location, fetch weather
- "Send an email to everyone who attended yesterday's meeting" → Get calendar, extract attendees, send emails
Each of these requires Claude to:
- Call one tool to get information
- Use that information to determine what to do next
- Call another tool with the results from step 1
- Potentially repeat until it has everything needed
- Finally provide an answer to the user
Detecting Tool Requests
The key to knowing whether Claude wants to use a tool lies in the stop_reason field of the response message. When Claude decides it needs to call a tool, this field gets set to "tool_use". This gives us a clean way to check if we need to continue the conversation loop:
if response.stop_reason != "tool_use":
break # Claude is done, no more tools needed
Possible Stop Reasons
"end_turn"— Claude finished its response naturally (final answer)"tool_use"— Claude is requesting one or more tools"max_tokens"— Response was cut off due to token limit"stop_sequence"— A stop sequence was encountered
For tool conversations, you primarily care about distinguishing between "tool_use" and everything else.
The Conversation Loop
The main conversation function follows a simple pattern:
def run_conversation(messages, tools):
"""
Runs a conversation loop that handles multiple tool calls automatically.
Continues until Claude provides a final text response.
"""
while True:
# Get Claude's response with tools available
response = chat(messages, tools=tools)
# Add Claude's response to the conversation
add_assistant_message(messages, response)
# Display any text in the response
response_text = text_from_message(response)
if response_text:
print(response_text)
# Check stop_reason to see if we're done
if response.stop_reason != "tool_use":
break # No more tools needed
# Execute the requested tools
tool_results = run_tools(response)
# Add tool results back to the conversation
add_user_message(messages, tool_results)
return messages
This loop continues until Claude provides a final answer without requesting any tools.
Handling Multiple Tool Calls
Claude can request multiple tools in a single response. The message content contains a list of blocks, and we need to process each tool use block separately.
Processing All Tool Requests
The run_tools function handles this by filtering for tool use blocks and processing each one:
def run_tools(message):
"""
Executes all tool use blocks in a message and returns tool result blocks.
"""
# Filter for tool use blocks
tool_requests = [
block for block in message.content if block.type == "tool_use"
]
tool_result_blocks = []
for tool_request in tool_requests:
# Process each tool request
try:
result = run_tool(tool_request.name, tool_request.input)
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": json.dumps(result),
"is_error": False
})
except Exception as e:
# Handle errors gracefully
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": f"Error: {str(e)}",
"is_error": True
})
return tool_result_blocks
Tool Result Blocks
Each tool use block must be answered with a corresponding tool result block. The connection between them is maintained through matching IDs.
The Link: tool_use_id
# Claude's tool request
tool_use_block = {
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9817835", # ← Generated by Claude
"name": "get_current_datetime",
"input": {}
}
# Your tool result (must reference the same ID)
tool_result_block = {
"type": "tool_result",
"tool_use_id": "toolu_01A09q90qw90lq917835lq9817835", # ← Must match
"content": "2024-01-15T10:30:00Z",
"is_error": False
}
Complete Tool Result Structure
tool_result_block = {
"type": "tool_result",
"tool_use_id": tool_request.id, # Links to the original request
"content": json.dumps(tool_output), # Tool output as string
"is_error": False # Set to True if tool execution failed
}
Important fields:
type: Must be"tool_result"tool_use_id: Must match theidfrom the tool use blockcontent: String containing the tool output (usejson.dumps()for objects)is_error: Boolean indicating whether the tool execution failed
Error Handling
Robust tool execution requires handling potential errors. When a tool fails, we still need to provide a result block to Claude:
def run_tools(message):
tool_requests = [
block for block in message.content if block.type == "tool_use"
]
tool_result_blocks = []
for tool_request in tool_requests:
try:
# Attempt to execute the tool
tool_output = run_tool(tool_request.name, tool_request.input)
tool_result_block = {
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": json.dumps(tool_output),
"is_error": False
}
except Exception as e:
# Tool execution failed
print(f"⚠️ Tool error: {str(e)}")
tool_result_block = {
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": f"Error: {str(e)}",
"is_error": True
}
tool_result_blocks.append(tool_result_block)
return tool_result_blocks
Why is_error Matters
Setting is_error: True tells Claude that the tool failed. Claude can then:
- Try a different approach
- Ask the user for clarification
- Provide a graceful error message
- Attempt to use alternative tools
Example:
User: "What's the weather in Atlantis?"
→ get_weather(location="Atlantis")
→ Error: "Location not found"
→ Claude: "I couldn't find weather data for Atlantis. Could you provide a valid city name?"
Scalable Tool Routing
To support multiple tools, create a routing function that maps tool names to their implementations:
def run_tool(tool_name, tool_input):
"""
Routes tool requests to the appropriate implementation.
Raises an exception if the tool is unknown.
"""
if tool_name == "get_current_datetime":
return get_current_datetime(**tool_input)
elif tool_name == "add_duration_to_datetime":
return add_duration_to_datetime(**tool_input)
elif tool_name == "get_weather":
return get_weather(**tool_input)
elif tool_name == "send_email":
return send_email(**tool_input)
else:
raise ValueError(f"Unknown tool: {tool_name}")
Alternative: Dictionary-Based Routing
For cleaner code with many tools:
# Define tool registry
TOOL_REGISTRY = {
"get_current_datetime": get_current_datetime,
"add_duration_to_datetime": add_duration_to_datetime,
"get_weather": get_weather,
"send_email": send_email,
"search_database": search_database,
"create_calendar_event": create_calendar_event,
}
def run_tool(tool_name, tool_input):
if tool_name not in TOOL_REGISTRY:
raise ValueError(f"Unknown tool: {tool_name}")
tool_function = TOOL_REGISTRY[tool_name]
return tool_function(**tool_input)
This approach makes it easy to add new tools without modifying the routing logic—just add entries to the registry.
Using Multiple Tools: A Practical Example
Adding multiple tools to your Claude implementation becomes straightforward once you have the core tool-handling infrastructure in place. Let's build a reminder system that demonstrates how to integrate additional tools by following a simple pattern.
The Tools We're Adding
We need three main capabilities for our reminder system:
- Get current date time — Claude needs to know the current date and time
- Add duration to date time — Claude isn't perfect with date time addition, so we provide a tool
- Set a reminder — Need a way to actually create the reminder
The good news is that most of the implementation work is already done. The conversation loop, message handlers, and tool execution framework are all in place. We just need to add the new tools.
Step 1: Define Tool Implementations
First, implement the actual tool functions:
from datetime import datetime, timedelta
import json
def get_current_datetime():
"""
Returns the current date and time in ISO format.
"""
return datetime.now().isoformat()
def add_duration_to_datetime(date, days=0, hours=0, minutes=0):
"""
Adds a duration to a given datetime.
"""
dt = datetime.fromisoformat(date)
dt += timedelta(days=days, hours=hours, minutes=minutes)
return dt.isoformat()
def set_reminder(date, message):
"""
Sets a reminder for a specific date with a message.
In production, this would save to a database.
"""
return {
"status": "success",
"reminder_id": "rem_12345",
"date": date,
"message": message
}
Step 2: Define Tool Schemas
Create JSON schemas that describe each tool to Claude:
get_current_datetime_schema = {
"name": "get_current_datetime",
"description": "Returns the current date and time in ISO 8601 format",
"input_schema": {
"type": "object",
"properties": {},
"required": []
}
}
add_duration_to_datetime_schema = {
"name": "add_duration_to_datetime",
"description": "Adds a specified duration (days, hours, minutes) to a given datetime. Returns the new datetime in ISO format.",
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "ISO 8601 datetime string (e.g., '2024-01-15T10:30:00')"
},
"days": {
"type": "integer",
"description": "Number of days to add (can be negative)",
"default": 0
},
"hours": {
"type": "integer",
"description": "Number of hours to add",
"default": 0
},
"minutes": {
"type": "integer",
"description": "Number of minutes to add",
"default": 0
}
},
"required": ["date"]
}
}
set_reminder_schema = {
"name": "set_reminder",
"description": "Creates a reminder for a specific date and time with a custom message",
"input_schema": {
"type": "object",
"properties": {
"date": {
"type": "string",
"description": "ISO 8601 datetime string for when the reminder should trigger"
},
"message": {
"type": "string",
"description": "The reminder message to display"
}
},
"required": ["date", "message"]
}
}
Step 3: Add Tools to the Conversation
Update the run_conversation function to include the new tool schemas in the tools list:
def run_conversation(user_query, max_turns=10):
messages = []
add_user_message(messages, user_query)
# Define all available tools
tools = [
get_current_datetime_schema,
add_duration_to_datetime_schema,
set_reminder_schema
]
for turn in range(1, max_turns + 1):
print(f"\n--- Turn {turn} ---")
# Pass tools to Claude
response = chat(messages, tools=tools)
add_assistant_message(messages, response)
response_text = text_from_message(response)
if response_text:
print(f"Claude: {response_text}")
if response.stop_reason != "tool_use":
print(f"\n✅ Conversation complete")
return response_text
tool_results = run_tools(response)
add_user_message(messages, tool_results)
return "Error: Maximum conversation turns exceeded"
This tells Claude about all three available tools it can use during the conversation.
Step 4: Update the Tool Router
Modify the run_tool function to handle the new tool calls. Add elif cases for each new tool:
def run_tool(tool_name, tool_input):
"""
Routes tool requests to implementations.
"""
if tool_name == "get_current_datetime":
return get_current_datetime(**tool_input)
elif tool_name == "add_duration_to_datetime":
return add_duration_to_datetime(**tool_input)
elif tool_name == "set_reminder":
return set_reminder(**tool_input)
else:
raise ValueError(f"Unknown tool: {tool_name}")
The pattern is simple: check the tool name, call the corresponding function with the provided input, and return the result.
Using the Tool Registry Pattern
Alternatively, update your tool registry:
TOOL_REGISTRY = {
"get_current_datetime": get_current_datetime,
"add_duration_to_datetime": add_duration_to_datetime,
"set_reminder": set_reminder,
}
def run_tool(tool_name, tool_input):
if tool_name not in TOOL_REGISTRY:
raise ValueError(f"Unknown tool: {tool_name}")
return TOOL_REGISTRY[tool_name](**tool_input)
Testing Multiple Tool Usage
To test the system, try a request that requires multiple tools:
user_query = "Set a reminder for my doctor's appointment. It's 177 days after Jan 1st, 2050."
answer = run_conversation(user_query)
print(f"\nFinal answer: {answer}")
What Happens Behind the Scenes
This request forces Claude to:
- Calculate the date using
add_duration_to_datetime - Set the reminder using
set_reminder
Example Console Output
--- Turn 1 ---
Claude: I'll help you set a reminder for your doctor's appointment. Let me first calculate the date.
🔧 Calling tool: add_duration_to_datetime
Input: {'date': '2050-01-01T00:00:00', 'days': 177}
Result: 2050-06-27T00:00:00
--- Turn 2 ---
Claude: The date is June 27, 2050. Now I'll set the reminder.
🔧 Calling tool: set_reminder
Input: {'date': '2050-06-27T00:00:00', 'message': "Doctor's appointment"}
Result: {"status": "success", "reminder_id": "rem_12345", "date": "2050-06-27T00:00:00", "message": "Doctor's appointment"}
--- Turn 3 ---
Claude: ✅ I've set a reminder for your doctor's appointment on June 27, 2050. Your reminder ID is rem_12345.
✅ Conversation complete
Final answer: ✅ I've set a reminder for your doctor's appointment on June 27, 2050. Your reminder ID is rem_12345.
Claude handles this by first explaining what it needs to do, then making the appropriate tool calls in sequence.
Understanding the Message Flow
When you examine the conversation history, you'll see the complete message structure:
Turn 1: User Request
{
"role": "user",
"content": "Set a reminder for my doctor's appointment. It's 177 days after Jan 1st, 2050."
}
Turn 2: Assistant with Tool Use
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll help you set a reminder. Let me calculate the date."
},
{
"type": "tool_use",
"id": "toolu_01ABC...",
"name": "add_duration_to_datetime",
"input": {"date": "2050-01-01T00:00:00", "days": 177}
}
]
}
Turn 3: Tool Results
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01ABC...",
"content": "2050-06-27T00:00:00",
"is_error": false
}
]
}
Turn 4: Assistant with Another Tool Use
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "The date is June 27, 2050. Now I'll set the reminder."
},
{
"type": "tool_use",
"id": "toolu_02XYZ...",
"name": "set_reminder",
"input": {"date": "2050-06-27T00:00:00", "message": "Doctor's appointment"}
}
]
}
This demonstrates how Claude can include multiple blocks in a single message—combining explanatory text with tool usage requests.
The Simple Pattern for Adding Tools
Once you have the core tool infrastructure, adding new tools follows this pattern:
4-Step Process
-
Create the tool function implementation
def my_new_tool(param1, param2): # Implementation here return result -
Define the tool schema
my_new_tool_schema = { "name": "my_new_tool", "description": "What this tool does", "input_schema": { ... } } -
Add the schema to the tools list
tools = [ existing_tool_schema, my_new_tool_schema # ← Add here ] -
Add a case in the tool router
TOOL_REGISTRY["my_new_tool"] = my_new_tool # Or add elif in run_tool function
Example: Adding a Weather Tool
# 1. Implementation
def get_weather(location, units="celsius"):
# Call weather API
return {
"location": location,
"temperature": 22,
"conditions": "Partly cloudy",
"units": units
}
# 2. Schema
get_weather_schema = {
"name": "get_weather",
"description": "Gets current weather for a location",
"input_schema": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
"units": {"type": "string", "enum": ["celsius", "fahrenheit"]}
},
"required": ["location"]
}
}
# 3. Add to tools list
tools = [
get_current_datetime_schema,
add_duration_to_datetime_schema,
set_reminder_schema,
get_weather_schema # ← New tool
]
# 4. Add to registry
TOOL_REGISTRY["get_weather"] = get_weather
That's it! Claude can now use the weather tool alongside all your existing tools.
Testing Complex Multi-Tool Scenarios
Let's test a scenario that requires all three tools:
user_query = """
Set a reminder for my doctor's appointment.
It's 177 days after Jan 1st, 2050.
"""
answer = run_conversation(user_query)
Expected Tool Call Sequence
Turn 1:
Claude: "I'll calculate the date and set a reminder."
→ add_duration_to_datetime(date="2050-01-01T00:00:00", days=177)
Turn 2:
Tool result: "2050-06-27T00:00:00"
Claude: "That's June 27, 2050. Setting the reminder now."
→ set_reminder(date="2050-06-27T00:00:00", message="Doctor's appointment")
Turn 3:
Tool result: {"status": "success", "reminder_id": "rem_12345", ...}
Claude: "✅ Reminder set for June 27, 2050 (ID: rem_12345)"
stop_reason: "end_turn"
Understanding Message Blocks
Claude's responses can contain multiple content blocks in a single message:
Mixed Content Example
# A single assistant message with both text and tool use
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "I'll help you with that. Let me check the current time first."
},
{
"type": "tool_use",
"id": "toolu_01ABC",
"name": "get_current_datetime",
"input": {}
},
{
"type": "text",
"text": "And I'll also check the weather."
},
{
"type": "tool_use",
"id": "toolu_02XYZ",
"name": "get_weather",
"input": {"location": "San Francisco"}
}
]
}
This demonstrates how Claude can:
- Explain what it's doing (text blocks)
- Request multiple tools (tool use blocks)
- Interleave text and tool requests naturally
Modular Tool Architecture Benefits
This modular approach makes it easy to expand your AI assistant's capabilities without restructuring existing code. Each new tool integrates seamlessly with the existing conversation flow and tool-handling logic.
Benefits
✅ No core logic changes — Conversation loop stays the same
✅ Easy to test — Test each tool function independently
✅ Simple to maintain — Tools are self-contained
✅ Scalable — Add 10 tools or 100 tools with the same pattern
✅ Type-safe — Schemas validate inputs automatically
Anti-Pattern: Don't Do This
❌ Hardcoding tool logic in the conversation loop:
# Bad: Tool-specific logic in the loop
if response contains "reminder":
set_reminder(...)
elif response contains "weather":
get_weather(...)
✅ Do this instead:
# Good: Generic tool execution
tool_results = run_tools(response) # Handles any tool
Complete Working Example
Here's the full code with all three tools integrated:
import json
from datetime import datetime, timedelta
from anthropic import Anthropic
from anthropic.types import Message
client = Anthropic(api_key="your-api-key")
model = "claude-3-5-sonnet-20241022"
# Tool implementations
def get_current_datetime():
return datetime.now().isoformat()
def add_duration_to_datetime(date, days=0, hours=0, minutes=0):
dt = datetime.fromisoformat(date)
dt += timedelta(days=days, hours=hours, minutes=minutes)
return dt.isoformat()
def set_reminder(date, message):
return {
"status": "success",
"reminder_id": "rem_12345",
"date": date,
"message": message
}
# Tool registry
TOOL_REGISTRY = {
"get_current_datetime": get_current_datetime,
"add_duration_to_datetime": add_duration_to_datetime,
"set_reminder": set_reminder,
}
# Tool schemas (defined above)
tools = [
get_current_datetime_schema,
add_duration_to_datetime_schema,
set_reminder_schema
]
# Helper functions (from previous sections)
def add_user_message(messages, message):
user_message = {
"role": "user",
"content": message.content if isinstance(message, Message) else message
}
messages.append(user_message)
def add_assistant_message(messages, message):
assistant_message = {
"role": "assistant",
"content": message.content if isinstance(message, Message) else message
}
messages.append(assistant_message)
def chat(messages, system=None, temperature=1.0, tools=None):
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
"temperature": temperature,
}
if tools:
params["tools"] = tools
if system:
params["system"] = system
return client.messages.create(**params)
def text_from_message(message):
return "\n".join(
[block.text for block in message.content if block.type == "text"]
)
def run_tool(tool_name, tool_input):
if tool_name not in TOOL_REGISTRY:
raise ValueError(f"Unknown tool: {tool_name}")
return TOOL_REGISTRY[tool_name](**tool_input)
def run_tools(message):
tool_requests = [
block for block in message.content if block.type == "tool_use"
]
tool_result_blocks = []
for tool_request in tool_requests:
try:
print(f"🔧 Calling tool: {tool_request.name}")
print(f" Input: {tool_request.input}")
tool_output = run_tool(tool_request.name, tool_request.input)
print(f" Result: {tool_output}\n")
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": json.dumps(tool_output),
"is_error": False
})
except Exception as e:
print(f" ❌ Error: {str(e)}\n")
tool_result_blocks.append({
"type": "tool_result",
"tool_use_id": tool_request.id,
"content": f"Error: {str(e)}",
"is_error": True
})
return tool_result_blocks
def run_conversation(user_query, max_turns=10):
messages = []
add_user_message(messages, user_query)
for turn in range(1, max_turns + 1):
print(f"\n--- Turn {turn} ---")
response = chat(messages, tools=tools)
add_assistant_message(messages, response)
response_text = text_from_message(response)
if response_text:
print(f"Claude: {response_text}")
if response.stop_reason != "tool_use":
print(f"\n✅ Conversation complete")
return response_text
tool_results = run_tools(response)
add_user_message(messages, tool_results)
return "Error: Maximum conversation turns exceeded"
# Run it
answer = run_conversation("Set a reminder for my doctor's appointment. It's 177 days after Jan 1st, 2050.")
Complete Workflow
The complete multi-turn conversation works like this:
Step-by-Step Flow
- Send user message to Claude with available tools
- Claude responds with text and/or tool requests
- Check
stop_reason:- If
"tool_use": Continue to step 4 - If anything else: Done, return final answer
- If
- Execute all requested tools and create result blocks
- Send tool results back as a user message
- Repeat from step 2
Visual Example
┌─────────────────────────────────────────────────────────────┐
│ Turn 1 │
├─────────────────────────────────────────────────────────────┤
│ User: "What day is 103 days from today?" │
│ Claude: [tool_use: get_current_datetime] │
│ stop_reason: "tool_use" → Continue │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Turn 2 │
├─────────────────────────────────────────────────────────────┤
│ Tool result: "2024-01-15" │
│ Claude: [tool_use: add_duration_to_datetime(...)] │
│ stop_reason: "tool_use" → Continue │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Turn 3 │
├─────────────────────────────────────────────────────────────┤
│ Tool result: "2024-04-28" │
│ Claude: "103 days from today will be April 28, 2024." │
│ stop_reason: "end_turn" → Done! │
└─────────────────────────────────────────────────────────────┘
Refactoring Helper Functions
Before implementing the conversation loop, you need to update your helper functions to handle multiple message blocks properly.
The Problem with Current Helpers
Your existing helper functions probably look like this:
def add_user_message(messages, text):
messages.append({"role": "user", "content": text})
def add_assistant_message(messages, text):
messages.append({"role": "assistant", "content": text})
These only work with plain text strings. But tool responses contain structured content blocks, not just text.
Updating Message Handlers
Your add_user_message and add_assistant_message functions need to handle multiple content types:
from anthropic.types import Message
def add_user_message(messages, message):
"""
Adds a user message to the conversation.
Accepts: string, list of content blocks, or Message object
"""
user_message = {
"role": "user",
"content": message.content if isinstance(message, Message) else message
}
messages.append(user_message)
def add_assistant_message(messages, message):
"""
Adds an assistant message to the conversation.
Accepts: string, list of content blocks, or Message object
"""
assistant_message = {
"role": "assistant",
"content": message.content if isinstance(message, Message) else message
}
messages.append(assistant_message)
What Changed
- Type checking: Uses
isinstance(message, Message)to detect Message objects - Content extraction: Pulls
.contentfrom Message objects, uses raw value otherwise - Flexibility: Now accepts strings, content block lists, or full Message objects
Updating the Chat Function
Modify your chat function to accept a list of tools and return the full message instead of just text:
def chat(messages, system=None, temperature=1.0, stop_sequences=[], tools=None):
"""
Sends messages to Claude and returns the full Message object.
Now supports tool definitions.
"""
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
"temperature": temperature,
"stop_sequences": stop_sequences,
}
# Add tools if provided
if tools:
params["tools"] = tools
# Add system prompt if provided
if system:
params["system"] = system
# Return full Message object, not just text
message = client.messages.create(**params)
return message
What Changed
- Tools parameter: Accepts optional
toolslist - Full message return: Returns complete
Messageobject instead of extracting text - Preserves all blocks: Tool use blocks, text blocks, and other content types are retained
Extracting Text from Messages
Since you're now returning full message objects, create a helper to extract text when needed:
def text_from_message(message):
"""
Extracts all text content from a Message object.
Useful for displaying final responses to users.
"""
return "\n".join(
[block.text for block in message.content if block.type == "text"]
)
When to Use This
- Displaying final answers to users
- Logging conversation for debugging
- Extracting responses for evaluation
Common Patterns
Pattern 1: Information Gathering
Claude calls multiple tools to collect data before answering:
User: "Summarize my day"
→ get_calendar_events()
→ get_emails()
→ get_tasks()
→ Final summary
Pattern 2: Sequential Processing
Each tool depends on the previous result:
User: "Book a meeting room for tomorrow at 2pm"
→ get_current_datetime()
→ check_room_availability(date, time)
→ book_room(room_id, date, time)
→ Confirmation message
Pattern 3: Conditional Branching
Claude decides which tools to call based on results:
User: "Is it going to rain tomorrow?"
→ get_current_location()
→ get_weather_forecast(location)
→ If rain > 50%: get_umbrella_reminder()
→ Final answer
Best Practices
1. Always Set a Turn Limit
Prevent infinite loops in production:
max_turns = 10 # Reasonable limit for most use cases
2. Log Everything During Development
Makes debugging much easier:
print(f"Turn {turn}: stop_reason={response.stop_reason}")
print(f"Tool requests: {len(tool_requests)}")
3. Return Structured Tool Outputs
Use JSON for complex data:
return json.dumps({
"date": "2024-04-28",
"day_of_week": "Sunday",
"formatted": "April 28, 2024"
})
4. Handle Errors Gracefully
Always set is_error: True when tools fail so Claude can adapt.
5. Test with Complex Scenarios
Don't just test happy paths—try:
- Invalid tool inputs
- Missing required parameters
- Tools that return errors
- Requests requiring 5+ tool calls
Key Takeaways
- Use
stop_reasonto detect tool requests — it's more reliable than checking content blocks - Handle multiple tool calls per turn — Claude can request several tools at once
- Match tool results to requests via
tool_use_id— this links responses correctly - Always include
is_errorin result blocks — helps Claude handle failures gracefully - Use dictionary-based routing for scalable tool management
- Follow the 4-step pattern for adding new tools
- Set turn limits to prevent infinite loops
- Log tool calls for debugging and monitoring
This creates a seamless experience where Claude can use multiple tools across several turns to fully answer complex user requests. The conversation history maintains the complete context, allowing Claude to build upon previous tool results to provide comprehensive responses.
Downloads
📓 001_tools_007.ipynb — Complete Jupyter notebook with multi-turn tool implementation
Next: Learn about parallel tool use to handle multiple independent tool calls simultaneously.