Jarvis · Chapter 8

Giving Jarvis Hands 🔧

This is the chapter that turns a chatbot into an assistant, and it is the one people find most mysterious. It should not be: the model never runs anything. It asks, and your code decides.

Goal

Understand the tool-use loop completely, and get your assistant calling its first real function.

about 35 minutes

The one sentence that demystifies tools

The model cannot run code. It can only ask you to.

When your assistant "checks the time", what actually happens is that it sends back a message meaning "please call get_current_time and tell me what it says". Your Python runs the function. Your Python sends the answer back. The model then writes a sentence about it.

Every safety property in this chapter follows from that. It cannot use a tool you did not write. It cannot pass arguments your function does not accept. It is a very well-informed colleague who can only ask you to press buttons.

Describing a tool

You hand over a list of tool descriptions. This is just a dictionary:

TOOLS = [
    {
        "name": "get_current_time",
        "description": (
            "Get the current date and time on the user's computer. "
            "Use this whenever the user asks about the time, today's date, "
            "or how long until something."
        ),
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": [],
        },
    },
]

print("tool name       :", TOOLS[0]["name"])
print("takes arguments :", bool(TOOLS[0]["input_schema"]["properties"]))
print("description len :", len(TOOLS[0]["description"]), "characters")
tool name       : get_current_time
takes arguments : False
description len : 144 characters

Three fields matter:

The loop

Here is the whole thing. Read the comments; they are the lesson.

import anthropic
from datetime import datetime

client = anthropic.Anthropic()
MODEL = "claude-haiku-4-5"


def get_current_time():
    return datetime.now().strftime("%A %d %B %Y, %H:%M")


def run_tool(name, tool_input):
    """Actually execute a tool. This is YOUR code, not the model's."""
    if name == "get_current_time":
        return get_current_time()
    return f"Unknown tool: {name}"


messages = [{"role": "user", "content": "What time is it?"}]

while True:
    response = client.messages.create(
        model=MODEL,
        max_tokens=1000,
        tools=TOOLS,
        messages=messages,
    )

    # It is done talking and wants nothing more from us.
    if response.stop_reason != "tool_use":
        break

    # Keep the assistant turn EXACTLY as it came back, tool requests and all.
    messages.append({"role": "assistant", "content": response.content})

    results = []
    for block in response.content:
        if block.type == "tool_use":
            output = run_tool(block.name, block.input)
            results.append({
                "type": "tool_result",
                "tool_use_id": block.id,   # must match the request
                "content": output,
            })

    # Results go back as a USER turn. That surprises everyone.
    messages.append({"role": "user", "content": results})

print("".join(b.text for b in response.content if b.type == "text"))

What actually happened, step by step

you> what time is it?

  [call 1] you send: "what time is it?" + the tool list
  [call 1] it replies: stop_reason="tool_use", wants get_current_time()
  [your code] runs get_current_time() -> "Monday 17 August 2026, 14:32"
  [call 2] you send: everything above + the tool result
  [call 2] it replies: stop_reason="end_turn", text="It is 2:32pm on Monday."

jarvis> It is 2:32pm on Monday, 17 August 2026.

Note there were two API calls for one question. That is normal and it is why tool use costs more than plain chat. Every tool round trip is another call carrying the whole conversation.

The four things people get wrong

Every one of these produces a confusing error, so they are worth naming.

  1. Appending your own summary instead of response.content. You must append the assistant turn exactly as it arrived, tool-use blocks included. The follow-up call needs to see its own request. Replace it with a tidy string and the API rejects the conversation.
  2. Sending tool results as an assistant message. They go back with "role": "user". It feels wrong, because the user did not say it, but the protocol treats anything you feed in as coming from your side.
  3. Mismatched tool_use_id. Each result must carry the id of the request it answers, so several parallel tool calls can be matched up. Copy it from the block; never invent it.
  4. Looping forever. If you break only on end_turn and something unexpected comes back, you spin, calling the API repeatedly, spending money. Chapter 12 adds a hard limit on rounds.

Several tools at once

The loop above already handles this: the for over response.content collects every tool_use block, and all the results go back in one message. A model can ask for three things in a single turn, and your code answers all three together rather than one at a time.

The shortcut, and why not yet

The SDK ships a tool runner that writes this loop for you: you decorate plain Python functions and it handles the round trips. It is genuinely nice, and it is in beta.

Learn it after this. The loop you just wrote is the actual mental model of every AI agent there is, including the elaborate ones. Fifteen lines, and once you have written them yourself, no agent framework will ever be mysterious again. Reach for the shortcut when you are bored of the loop, not before you understand it.

If it went wrong

✅ Checkpoint

Ask your assistant what time it is and it answers correctly, having actually run your Python function to find out. Ask it something unrelated and it does not call the tool.

+150 XP