6Tool use with Claude

Implementing multiple turns

6 min read1,171 words

The helpers are ready. Time to write the loop that turns "here's a user question" into "here's a final answer, after however many tool calls Claude needed in between." This is the piece of code you'll copy into every tool-enabled Claude project you ever build — and once you have it, you stop thinking about tool calling as a choreography problem and start thinking about it as a feature of the API.

The stop condition: stop_reason == "tool_use"

Every API response carries a stop_reason field that tells you why Claude stopped generating. When Claude chose to call a tool, the response ends with tool_use blocks and stop_reason is "tool_use". When Claude has a final answer, stop_reason is "end_turn" (or occasionally another non-tool reason).

That field is the loop's termination signal:

if response.stop_reason != "tool_use":
    break  # final answer, we're done

Simple as it gets: keep calling Claude as long as it keeps asking for tools, stop as soon as it doesn't.

The conversation loop

def run_conversation(messages):
    while True:
        response = chat(messages, tools=[get_current_datetime_schema])
        add_assistant_message(messages, response)
        print(text_from_message(response))

        if response.stop_reason != "tool_use":
            break

        tool_results = run_tools(response)
        add_user_message(messages, tool_results)

    return messages

Walk through one iteration:

  1. Call Claude with the current message history and the available tools.
  2. Append the assistant response — whole Message, preserving every block — to the history.
  3. Print the text portion (optional but useful during development for seeing what Claude is thinking between tool calls).
  4. Check the stop reason. If it's anything other than "tool_use", we have a final answer and we break.
  5. Otherwise, run the tools — execute the functions Claude requested and collect their results.
  6. Append the tool results as a user message carrying a list of tool_result blocks.
  7. Loop.

The loop runs until Claude stops asking for tools. One turn per iteration of the loop. The conversation history grows by two messages each iteration — one assistant, one user-with-results — except on the final iteration, where only the assistant message is appended and then we break.

run_tools — processing every tool call in a response

Claude may request multiple tools in a single response, so run_tools iterates over all the tool_use blocks and returns a list of matching tool_result blocks:

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:
            tool_output = run_tool(tool_request.name, tool_request.input)
            result_block = {
                "type": "tool_result",
                "tool_use_id": tool_request.id,
                "content": json.dumps(tool_output),
                "is_error": False,
            }
        except Exception as e:
            result_block = {
                "type": "tool_result",
                "tool_use_id": tool_request.id,
                "content": f"Error: {e}",
                "is_error": True,
            }

        tool_result_blocks.append(result_block)

    return tool_result_blocks

Three things to notice:

  • Filter by block.type == "tool_use". The content list also contains text blocks — you want only the tool call blocks.
  • Wrap every tool invocation in try/except. A tool raising an exception shouldn't kill the whole conversation — it should come back to Claude as a failed tool result with is_error: True, and Claude can decide how to recover.
  • Serialise tool output with json.dumps. The content field of a tool_result block is a string; anything richer gets flattened.

run_tool — the routing function

run_tools calls a helper run_tool that maps a tool name to the actual Python function. With one tool, this is a simple if/elif:

def run_tool(tool_name, tool_input):
    if tool_name == "get_current_datetime":
        return get_current_datetime(**tool_input)

    raise ValueError(f"Unknown tool: {tool_name}")

As you add more tools in the following lessons, run_tool grows into a dispatch table:

def run_tool(tool_name, tool_input):
    if tool_name == "get_current_datetime":
        return get_current_datetime(**tool_input)
    if tool_name == "add_duration_to_datetime":
        return add_duration_to_datetime(**tool_input)
    if tool_name == "set_reminder":
        return set_reminder(**tool_input)

    raise ValueError(f"Unknown tool: {tool_name}")

Or, for something more scalable, a dict:

TOOL_IMPLS = {
    "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):
    fn = TOOL_IMPLS.get(tool_name)
    if fn is None:
        raise ValueError(f"Unknown tool: {tool_name}")
    return fn(**tool_input)

The dict form scales cleanly to dozens of tools and makes it trivial to reason about which tools your app supports. Start with the if/elif form for clarity in this module; switch to the dict when the tool count crosses a handful.

The ValueError on unknown tool names matters. If Claude ever asks for a tool you haven't registered — which shouldn't happen if your schemas are correct but occasionally will — you want a loud, traceable failure rather than silent data loss.

Error handling pays off in Claude's behaviour

Wrapping tool calls in try/except and returning is_error: True results isn't just defensive programming — it changes what Claude does on the next turn. When Claude sees:

tool_result(is_error=True, content="Error: date_format must be a strftime pattern")

It reads the error, understands what went wrong, and either:

  • Retries the call with corrected arguments, or
  • Gives up on the tool and tells the user something went wrong.

Both are preferable to "silently continues with garbage." This is why input validation in the tool functions — the habit from three lessons ago — pays off in the loop: every loud error is a chance for Claude to self-correct.

Putting it all together

Drop these functions into your notebook and run the "103 days from today" conversation. What you'll see in the printout:

  • Turn 1: Claude says "Let me check the current time..." and calls get_current_datetime.
  • Turn 2 (after you hand it the result): Claude says "Now I'll add 103 days to that..." and calls add_duration_to_datetime — well, it would if that tool were defined, which it's not yet.

In the next few lessons you'll add that second tool, extend run_tool to dispatch to it, and watch the same conversation loop stitch two tool calls together with no changes to the loop itself. That's the whole point of building the loop now — once it exists, adding tools is local: a new function, a new schema, a new line in run_tool. The conversation machinery is done.

Key Takeaways

  • 1The conversation loop terminates on response.stop_reason != 'tool_use' — Claude signals it is done calling tools by returning a final text answer.
  • 2Each iteration of the loop appends the assistant response, optionally prints the text, checks the stop reason, runs the tools, and appends the results as a user message.
  • 3run_tools iterates every tool_use block in the response, wrapping each invocation in try/except so a failing tool becomes an is_error result rather than a crashed conversation.
  • 4run_tool is the dispatch function that maps a tool name string to its Python implementation — start as an if/elif and grow into a dict lookup as the tool count scales.
  • 5Returning is_error True with an informative message lets Claude recover from tool failures by retrying or gracefully telling the user — loud errors beat silent garbage.
  • 6Once the loop exists, adding new tools is purely local: write the function, write the schema, add one line to run_tool — the conversation machinery never needs to change.