buttercup.sh updates unit 2

Unit 2 — tool calling: how a chat model gets hands

A language model cannot check the weather, read a file or send an email. It can only write text. Tool calling is the agreement that turns some of that text into an instruction you agree to carry out. That agreement is the whole of agents; everything later in this course is a refinement of it. This lesson is for beginners — no prior agent code assumed, working JavaScript and Python below, and an exercise you can run in the browser tab next to this one.

The one idea

Every beginner arrives expecting something clever, and the truth is flatter than that: the model never runs anything. You hand it a list of functions it is allowed to ask for. When it wants one, it stops generating prose and emits a small structured block — a name and some JSON arguments. Your program reads that block, calls your own ordinary function, and pastes the return value back into the conversation. Then you ask the model again.

That is it. That is the loop. Four stations, going round:

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
The agent loop. Station 3 is the only place anything actually happens, and it is your code, not the model's. Keep going round until the model answers with plain text instead of a tool call — that condition, and nothing more, is what “the agent finished” means.

Notice what is not in that picture. There is no memory, no planning module, no framework. The conversation array is the state; the loop is the program. A model with no tools is a writer. A model with tools and this loop is an agent.

What the model actually sends back

You describe a tool once, in JSON Schema — a name, a sentence of prose, and the shape of its arguments. The model reads that description the way it reads everything else, and produces arguments that fit the shape:

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 right-hand side is generated text — the same machinery that writes sentences, aimed at a schema. Which is why the description on the left is a prompt, not documentation, and why you will spend more time editing that one sentence than editing the loop.

Two consequences worth carrying with you. First, arguments arrive as JSON, so parse them — never match on the raw string. Second, because the description is a prompt, a tool the model keeps misusing is usually a tool you described badly. Writing descriptions the model reads the way you meant is the follow-on lesson in this unit.

The code — JavaScript

Complete and runnable. npm i @anthropic-ai/sdk, set ANTHROPIC_API_KEY, and run it with Node. Three parts: the function, the description, 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 loop. The entire agent, right here. ───────────────────────────
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 });

  // No tool wanted? The model is answering. We are done.
  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,                        // the id is the whole contract
      content: String(getWeather(b.input)),
    }));

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

The odd-looking part is that the tool results go back with role: "user". They are not from a user, and it still reads strangely after the hundredth time. It is simply where the protocol puts them: the model's turn, then the turn that answers it.

The code — Python

The same program, line for line. 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,            # the id is the whole contract
            "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) is the Python spelling of the JavaScript { city } destructure: the schema's property names become the function's parameter names, so the arguments unpack straight into the call.

What the conversation looks like afterwards

One question, one tool call, and the array has four entries. Watch it fill:

  1. user — Do I need an umbrella in Paris?
  2. assistanttool_use id: toolu_01A · get_weather · {"city": "Paris"}
  3. usertool_result tool_use_id: toolu_01A · 18°C, light rain
  4. assistant — Yes — light rain in Paris at 18°C. Take one.
Turn 3 is the one people get wrong. Its tool_use_id must be the exact id from turn 2, and every tool_use needs a matching result before you may send the array again. Mismatch the ids or skip one and the request is rejected — which, of the errors you will hit this week, is the kind you should hope for.

You send that whole array again on every turn. The model has no memory between requests; the array is the memory. A ten-step agent's last request contains all ten steps. That is why lesson 5 is about context, and why the bill grows the way it does.

Four rules that will save you a weekend

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 out, one message back. The shape to avoid is three user messages carrying one result each: it is legal, it works, and it quietly trains the model out of asking in parallel — so your agent takes three round trips where it used to take one, with nothing in the logs to blame.

One shortcut, now that you have written the loop by hand: both SDKs will run it for you — client.beta.messages.tool_runner() in Python with the @beta_tool decorator, client.beta.messages.toolRunner() in TypeScript with betaZodTool. Use them in real projects. Write the loop yourself exactly once, which is now, so that when the runner does something surprising you know what it is doing.

The exercise

Do this in the harness — it is the same loop, already running, 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

Watch the transcript rather than the answer. You are looking for the shape from 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.

Then break it on purpose, which is the half people skip:

Next week

A follow-on lesson in this unit gets to the part that actually decides whether your agent is any good: writing tool descriptions the model reads the way you meant. Then unit 3 — loops and goals takes this loop apart properly, and asks the question this lesson dodged. Here we stopped when the model stopped calling tools. What if it never stops?

Where this sits in the whole course, and what comes after: the syllabus.

If someone forwarded you this, the lessons are free and weekly and the archive keeps the ones you missed:

open the harness

One lesson a week