The Agent Loop and Tool Runner

The Loop with a Real API

  1. Send the messages and the tool definitions to the model.
  2. If stop_reason is "tool_use", run every tool_use block in the reply.
  3. Append the assistant reply (all of response.content) and then one user message with all the tool_result blocks, each with the matching tool_use_id.
  4. Call the model again. Stop when stop_reason is "end_turn".

Example

Example

What the message history looks like after a two-tool run. fake_model is a stand-in that returns API-shaped dictionaries, so you can see exactly what your loop must append.

import json

def fake_model(messages):  # stand-in for client.messages.create(...)
    if len(messages) == 1:
        return {"stop_reason": "tool_use", "content": [
            {"type": "text", "text": "Let me check both orders."},
            {"type": "tool_use", "id": "toolu_01", "name": "get_order", "input": {"order_id": "SE1042"}},
            {"type": "tool_use", "id": "toolu_02", "name": "get_order", "input": {"order_id": "SE1077"}}]}
    return {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "SE1042 is delivered; SE1077 is out for delivery."}]}

def get_order(order_id):
    return {"SE1042": "delivered", "SE1077": "out for delivery"}[order_id]

messages = [{"role": "user", "content": "Status of SE1042 and SE1077?"}]
while True:
    resp = fake_model(messages)
    messages.append({"role": "assistant", "content": resp["content"]})
    if resp["stop_reason"] == "end_turn":
        break
    results = [{"type": "tool_result", "tool_use_id": b["id"], "content": get_order(**b["input"])}
               for b in resp["content"] if b["type"] == "tool_use"]
    messages.append({"role": "user", "content": results})   # ALL results in ONE user turn

for m in messages:
    kinds = [m["content"]] if isinstance(m["content"], str) else [b["type"] for b in m["content"]]
    print(m["role"].ljust(9), json.dumps(kinds))
print("final:", messages[-1]["content"][0]["text"])

Output

user      ["Status of SE1042 and SE1077?"]
assistant ["text", "tool_use", "tool_use"]
user      ["tool_result", "tool_result"]
assistant ["text"]
final: SE1042 is delivered; SE1077 is out for delivery.

The Tool Runner

Writing the loop by hand is good for learning. In real code, the Python SDK's tool runner (beta) runs the loop for you: it builds the tool schema from your function signature and docstring, calls your function when Claude asks, and stops when Claude has no more tool calls.

import anthropic
from anthropic import beta_tool

client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY

@beta_tool
def get_order(order_id: str) -> str:
    """Look up a ShopEase order. Call this whenever the customer mentions an order ID.

    Args:
        order_id: The order ID, for example SE1042.
    """
    return {"SE1042": "delivered on 12 Oct", "SE1077": "out for delivery"}.get(order_id, "not found")

runner = client.beta.messages.tool_runner(
    model="claude-opus-5-5",
    max_tokens=16000,
    system="You are the ShopEase support assistant. Be brief.",
    tools=[get_order],
    messages=[{"role": "user", "content": "Status of SE1042 and SE1077?"}],
)
for message in runner:                  # one item per model turn
    for block in message.content:
        if block.type == "tool_use":
            print("tool call:", block.name, block.input)
        elif block.type == "text":
            print("assistant:", block.text)

Run it

python agent.py

Example output

tool call: get_order {'order_id': 'SE1042'}
tool call: get_order {'order_id': 'SE1077'}
assistant: SE1042 was delivered on 12 Oct. SE1077 is out for delivery today.

Note

Note: On Claude Opus 5.5 and Sonnet 5.5, forcing a tool with tool_choice any/tool returns a 400 error. Keep auto, say "use the get_order tool" in the prompt, and add "strict": True to a hand-written tool definition when you need schema-valid arguments.