Multi-turn conversations with tools
You've done the full round-trip for a single tool call. Now a user asks: "What day is 103 days from today?" Claude has the right idea — call get_current_datetime, then call add_duration_to_datetime with the result — but that's two tool calls in sequence, not one. The existing single-shot flow can't handle that. This lesson sets up the helpers you'll need to run an open-ended conversation loop.
The multi-turn tool pattern
Here's what the "103 days from today" conversation actually looks like:
- User asks the question.
- Claude responds with a
tool_userequestingget_current_datetime. - Your server calls the tool and returns the result.
- Claude looks at the result, realises it still needs to add 103 days, and responds with another
tool_userequestingadd_duration_to_datetime. - Your server calls the second tool and returns the result.
- Claude now has everything and responds with a final text answer.
Six steps, two tool calls, three assistant messages. The client code has to run a loop that keeps calling Claude until Claude stops asking for tools and produces a final text answer.
Before you can write that loop, you need to upgrade the helper functions you wrote back in Module 3. They were built for a plain-text world; they need a few tweaks to handle multi-block messages and the tools parameter.
Upgrading add_user_message and add_assistant_message
The originals assumed content is always a string:
def add_user_message(messages, text):
messages.append({"role": "user", "content": text})
That was fine when every message body was a single text string. Now user messages sometimes carry a list of tool_result blocks, and assistant messages can be full Message objects from the SDK carrying their own block lists. The upgrade handles all three cases:
from anthropic.types import Message
def add_user_message(messages, message):
content = message.content if isinstance(message, Message) else message
messages.append({"role": "user", "content": content})
def add_assistant_message(messages, message):
content = message.content if isinstance(message, Message) else message
messages.append({"role": "assistant", "content": content})
Now you can pass:
- A plain string —
add_user_message(messages, "hello")still works. - A list of blocks —
add_user_message(messages, tool_result_blocks)for tool results. - A whole Message object —
add_assistant_message(messages, response)after a Claude call, preserving its entire content list.
The isinstance(message, Message) check is the key. If we got a Message back from the SDK, unwrap its .content — otherwise pass whatever we got through directly.
Upgrading chat() — return the whole message, accept tools
The old chat() returned just the text string:
def chat(messages, system=None, temperature=1.0, stop_sequences=[]):
# ...
return message.content[0].text
That's a problem now. The caller needs access to the stop_reason, the tool_use blocks, the entire content list — not just a fragment. So chat() returns the full message object and also accepts a tools parameter:
def chat(messages, system=None, temperature=1.0, stop_sequences=[], tools=None):
params = {
"model": model,
"max_tokens": 1000,
"messages": messages,
"temperature": temperature,
"stop_sequences": stop_sequences,
}
if tools:
params["tools"] = tools
if system:
params["system"] = system
message = client.messages.create(**params)
return message
Three changes:
- New
toolsparameter, conditionally added toparamsthe same waysystemis. - Returns
message, notmessage.content[0].text. Callers now get the fullMessageobject. stop_sequencesalways passed through. A small defensive tweak — passing an empty list is harmless and simpler than anotherif.
Everything that used chat() before still works, because the returned Message object has .content[0].text available when you need it. But now you can also read response.stop_reason, iterate response.content for tool_use blocks, and access response.usage for token counts.
Extracting text when you actually need it
Sometimes you just want the readable text — for printing to the user, logging, or display in a UI. Write a small helper:
def text_from_message(message):
return "\n".join(
block.text for block in message.content if block.type == "text"
)
text_from_message walks the content blocks, keeps only the text blocks, and joins them. It's safe whether the message is a pure-text response or a mixed text-plus-tool-use response — it just pulls out whatever text is there.
Why these refactors matter
None of this is glamorous — it's plumbing. But without it, the conversation loop you're about to write would be drowning in boilerplate conditionals and type-checking. With the upgraded helpers:
- The loop can just append whatever comes back from Claude without worrying about shape.
- Tool results go in as lists, plain text goes in as strings, whole Messages go in as Messages — the helpers handle all three cases.
- The
chat()signature supports tools natively, so enabling tools for a call is one extra keyword argument, not a fork in the code path. - Displaying the final answer is a one-line call to
text_from_message.
In the next lesson you'll use these four helpers to build the actual conversation loop — run_conversation() — that keeps calling Claude until the tool calls stop and a final answer comes out.
Key Takeaways
- 1Multi-turn tool use happens whenever Claude needs more than one tool call to answer a single user question — each tool call becomes its own turn in the conversation.
- 2Upgrade add_user_message and add_assistant_message to accept strings, block lists, or full Message objects by branching on isinstance(message, Message) and pulling .content when appropriate.
- 3chat() should return the full Message object (not just text) so callers can inspect stop_reason, tool_use blocks, and usage alongside the text.
- 4Add a tools parameter to chat() that is conditionally included in the create() params, mirroring how system was added in earlier lessons.
- 5text_from_message is a small helper that extracts readable text from mixed multi-block messages — use it whenever you need the human-facing text.
- 6These refactors are plumbing, but they keep the upcoming conversation loop clean by centralising all the multi-block handling in one place.