6Tool use with Claude

Multi-turn conversations with tools

5 min read973 words

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:

  1. User asks the question.
  2. Claude responds with a tool_use requesting get_current_datetime.
  3. Your server calls the tool and returns the result.
  4. Claude looks at the result, realises it still needs to add 103 days, and responds with another tool_use requesting add_duration_to_datetime.
  5. Your server calls the second tool and returns the result.
  6. 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 stringadd_user_message(messages, "hello") still works.
  • A list of blocksadd_user_message(messages, tool_result_blocks) for tool results.
  • A whole Message objectadd_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 tools parameter, conditionally added to params the same way system is.
  • Returns message, not message.content[0].text. Callers now get the full Message object.
  • stop_sequences always passed through. A small defensive tweak — passing an empty list is harmless and simpler than another if.

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.