buttercup.sh lesson 2

Lesson 2: tool calling - how a chat model gets hands

Welcome back. A chat model writes text, and your code can help it do something useful with that text. It can't check the weather or send an email unless your program gives it a way to ask. Tool calling lets the model request work from your code. This lesson builds the twenty-line loop behind that exchange in JavaScript and Python. You don't need any agent code to start, and the exercise runs in the browser tab next to this one.

The video covers the loop, one trip round the ring. The lesson below includes the same material and copyable code. Watch on YouTube.

Meet the tool-calling loop

You give the model a list of functions it may request. When it chooses one, it returns a structured block containing the function name and JSON arguments. Your program reads the block, calls the function, adds its return value to the conversation, and asks the model again.

The loop has four stations:

1 · messagesthe conversation so far, plus your list of tools
request
2 · modelreplies with text or a tool_use block
append
tool_use
4 · resulta tool_result carrying the same id
tool_result
3 · your coderuns the real function, fetch, disk, database
Your code does the work at station 3. Continue until the model answers with text instead of requesting another tool.

The conversation array holds the state. The loop needs no memory service or agent library.

What the model sends back

You describe a tool with a name, an instruction, and a JSON Schema for its arguments. The model reads the instruction as part of the prompt and generates arguments that fit the schema:

you write, once
{
  name: "get_weather",
  description:
    "Current weather for one city.",
  input_schema: {
    type: "object",
    properties: {      city: { type: "string" }    },    required: ["city"]  }
}
the model writes, per call
{"city": "Paris"}
The model generates the right-hand side. The two lit lines name the property and make it required. The description is part of the prompt, so its wording matters.

Parse the JSON arguments instead of matching the raw string. If the model keeps misusing a tool, revise its description before changing the loop. The next lesson covers descriptions in depth.

Put it together in JavaScript

Install @anthropic-ai/sdk, set ANTHROPIC_API_KEY, and run this with Node. It has three parts: the function, the description, and the loop.

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();   // reads ANTHROPIC_API_KEY

// 1. the tool. A plain function. Nothing about it is special.
function getWeather({ city }) {
  const readings = { Paris: "18°C, light rain", Tokyo: "27°C, clear" };
  return readings[city] ?? `no reading for ${city}`;
}

// 2. the description. This is what the model reads, it is a prompt.
const tools = [{
  name: "get_weather",
  description: "Current weather for one city. Use this for any question " +
               "about temperature, rain, or conditions right now.",
  input_schema: {
    type: "object",
    properties: {
      city: { type: "string", description: "City name, e.g. Paris" },
    },
    required: ["city"],
  },
}];

// 3. the agent loop
const messages = [
  { role: "user", content: "Do I need an umbrella in Paris?" },
];

while (true) {
  const reply = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 4096,
    tools,
    messages,
  });

  // Append the reply *unchanged* and in full. Do not rebuild it from the
  // text, the blocks you drop are the ones the next turn needs.
  messages.push({ role: "assistant", content: reply.content });

  // Stop when the model answers without requesting a tool.
  if (reply.stop_reason !== "tool_use") {
    console.log(reply.content.filter(b => b.type === "text")
                             .map(b => b.text).join(""));
    break;
  }

  // It asked. Run every request, and answer every request.
  const results = reply.content
    .filter(b => b.type === "tool_use")
    .map(b => ({
      type: "tool_result",
      tool_use_id: b.id,                        // must match the request id
      content: String(getWeather(b.input)),
    }));

  messages.push({ role: "user", content: results });
}

Tool results go back with role: "user", even though they come from your program. The protocol alternates between the model's turn and the turn that answers it.

Or put it together in Python

The Python version follows the same structure. Install it with pip install anthropic.

import anthropic

client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY

# 1. the tool 
def get_weather(city):
    readings = {"Paris": "18°C, light rain", "Tokyo": "27°C, clear"}
    return readings.get(city, f"no reading for {city}")

# 2. the description
tools = [{
    "name": "get_weather",
    "description": "Current weather for one city. Use this for any question "
                   "about temperature, rain, or conditions right now.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. Paris"},
        },
        "required": ["city"],
    },
}]

# 3. the loop
messages = [
    {"role": "user", "content": "Do I need an umbrella in Paris?"},
]

while True:
    reply = client.messages.create(
        model="claude-opus-5",
        max_tokens=4096,
        tools=tools,
        messages=messages,
    )

    # Append the reply unchanged and in full, blocks and all.
    messages.append({"role": "assistant", "content": reply.content})

    if reply.stop_reason != "tool_use":
        print("".join(b.text for b in reply.content if b.type == "text"))
        break

    results = [
        {
            "type": "tool_result",
            "tool_use_id": block.id,            # must match the request id
            "content": str(get_weather(**block.input)),
        }
        for block in reply.content if block.type == "tool_use"
    ]

    messages.append({"role": "user", "content": results})

get_weather(**block.input) passes the schema's property names to matching Python parameters, much like the JavaScript { city } destructure.

The conversation after one tool call

After one question and one tool call, the array has four entries:

  1. user, Do I need an umbrella in Paris?
  2. assistant, tool_use id: toolu_01A · get_weather · {"city": "Paris"}
  3. user, tool_result tool_use_id: toolu_01A · 18°C, light rain
  4. assistant, Yes: light rain in Paris at 18°C. Take one.
Turn 3 must copy the exact tool_use_id from turn 2. Every tool_use needs a matching result before you send the array again. The API rejects missing or mismatched ids.

You send the full array on every turn because the model retains nothing between requests. A ten-step agent's last request carries all ten steps, which makes each request larger than the last. Lesson 5 covers context management.

request 1
0.3k
request 2
0.9k
request 3
2.0k
request 4
2.9k
request 5
4.2k
One agent run, five requests. The hatched part of each bar is the conversation you already sent; the solid part is the only new thing in it. A stateless API needs both parts on every request. Large tool output, such as a full file or page of JSON, is billed on every remaining turn.

The token cost

The five requests total 0.3k + 0.9k + 2.0k + 2.9k + 4.2k = 10.3k input tokens billed for a conversation that ends at 4.2k. You pay 2.5× its final size. At Claude Opus 5's $5 per million input tokens, that run costs about $0.05.

Suppose read_file returns an 8k-token source file on turn 2 of a ten-turn run. The next eight requests resend it: 8 × 8k = +64k input tokens, or $0.32 from one tool result. At a thousand runs a day, that untrimmed result costs $320 a day.

The browser harness also resends the full array at each step. Its max steps setting, 40 by default, limits how many times a large result can be sent and billed again.

one fat tool_result · a ten-turn run
turn 2 · read_filereturns the whole file, 8k tokens, and the array keeps it
  1. 3
  2. 4
  3. 5
  4. 6
  5. 7
  6. 8
  7. 9
  8. 10
+64k input tokens · $0.32 · per run
Each block is a later request carrying the same 8k tokens. The bill grows by one full tool result per request. If the tool returns only the twelve needed lines, the seven later requests no longer carry the rest of the file.
Trim tool results. A large result stays in the conversation and is billed again on every later turn.

Return less: line ranges instead of whole files, counts instead of dumps, or twenty matching rows instead of a full table. Cache the remaining prefix. cache_control makes a repeated prefix bill at roughly 0.1× the input rate, against a 1.25× premium the one time it's written. The cache saves money after two requests over the same prefix. Lesson 5 covers prefix caching in detail.

Four habits that keep the loop steady

station 3 throws ENOENT: notes/paris.md, the disk said no, and your function raised
  • you drop the result the array now holds a tool_use with nothing answering it, so the next request is the one that fails
  • you send it back a tool_result with is_error: true and the error message as its content
A tool may fail without breaking the loop, but each call still needs a result. Catch exceptions from your function and put the error in the content. The model can then fix the path, try another tool, or report the failure. An uncaught exception leaves the call unanswered, so the protocol breaks on the next turn.
one assistant turn · three tool_use blocks
  • read_file
  • list_dir
  • grep
one user message
  • tool_result · toolu_01A · 412 bytes
  • tool_result · toolu_01B · 9 entries
  • tool_result · toolu_01C · 2 matches
Three calls go out and one message comes back. Three user messages carrying one result each are legal, but they discourage parallel calls and turn one round trip into three.
turns taken · one per trip round the ring
turn 1, the question turn 20, you stop
The counter takes four lines of code. A model that has misread one tool description doesn't crash; it keeps asking for the same tool, and each turn carries the full array. The hatched ticks past the wall are the calls you never paid for. Pick a number, break at it, and log the fact that you did, because hitting the cap often means a description needs rewriting.

Choose the loop that fits your project

After writing the loop once, you can hand it to either SDK. Python provides client.beta.messages.tool_runner() with the @beta_tool decorator, and TypeScript provides client.beta.messages.toolRunner() with betaZodTool. They reduce the loop to about ten lines and handle the protocol rules above.

The runner owns the control flow. Approval gates, per-call logging, rewritten arguments, and custom turn counters must go through its hooks instead of a line inside your own while. The shorter code is useful when those hooks cover your needs. Writing the loop once makes the runner easier to debug.

The harness in the next tab doesn't use a tool runner. It has the loop from this lesson written by hand in js/agent.js, because it runs in a browser tab against fetch with no dependencies. It has the same four stations, plus a step counter and a settings panel.

Other libraries use different names for the same four stations:

Use the library that fits your stack. Identify its four stations and you can debug the loop beneath the API.

Your turn to take the loop around

When you're ready, open the harness, which runs the same loop with twenty-one tools wired to a virtual filesystem. Open KEYS, paste a key or point it at Ollama, then:

make notes/paris.md with three lines about the weather, then read it back

The transcript should match the diagram: a tool_use for the write, a tool_result confirming it, a second tool_use for the read. Two trips round the ring, then prose.

  1. user, make notes/paris.md with three lines about the weather, then read it back
  2. assistant, tool_use write_file · {"path": "notes/paris.md", …}
  3. user, tool_result wrote 3 lines, 87 bytes
  4. assistant, tool_use read_file · {"path": "notes/paris.md"}
  5. user, tool_result the three lines, back verbatim
  6. assistant, Written and read back. Here is what it says…
Check your run against these six entries. The model does not ask permission between the two trips. It writes, sees that the write worked, and only then reads. If your transcript shows the read before the result of the write has landed, you have found a sequencing bug: the loop must wait at station 3.

Then try these safe experiments:

A safe place to experiment

These tests stay inside the harness. It writes to a virtual filesystem in localStorage in your own tab, so write_file never touches your disk. /wipe deletes every file and the conversation with it, and /undo puts them back until you close the tab. Your key stays in the browser. The only real cost is tokens, and a run like the one above is a fraction of a cent.

Continue to lesson 3

The next lesson covers tool descriptions and how their wording guides the model. Lesson 3, loops and goals then examines agents that keep calling tools after they should stop.

This loop turns a model's request into an action your program can run. Each tool adds another possible action. The harder design problem is deciding which actions to expose.

See the syllabus for the rest of the course.

If someone forwarded this lesson, welcome. The free archive has the whole course waiting for you:

open the harness and give your model a tool

Want to keep learning together?