# How AI tool calling works (40 lines of vanilla JavaScript)

> Source: <https://buttercup.sh/lessons/2026-09-15-lesson-2-tool-calling.html>
> Published: 2026-09-15 18:27:24+00:00

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

Twenty lines of code turn a chat model into an agent. You'll have
         those twenty lines running today, in JavaScript and in Python, and
         you'll know what every one of them does. A model can't 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 refines it. No
         prior agent code assumed, and the exercise runs in the browser tab
         next to this one.

## The one idea

Beginners arrive expecting something clever. It's flatter than that:
       *the model never runs anything*. You hand it a list of functions
       it's allowed to ask for. When it wants one, it stops writing 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's it. That's the loop. Four stations, going round:

Notice what's *not* in that picture. No memory. No planning
       module. No framework. The conversation array is the state, and 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. Then it writes arguments that fit the shape:

Two consequences. First, arguments arrive as JSON, so **parse
       them**, never match on the raw string. Second, the description is
       a prompt, so 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 to this lesson.

You don't need a framework for any of this. A framework will run the loop for you later in the lesson, and it's the same four stations underneath.

## 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.

``` python
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: tool results go back with
       `role: "user"`. They aren't from a user. It still reads
       strangely on the hundredth time. It's 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`.

``` python
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:

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 carries all ten steps. That's why lesson 5 is about
       context, and why the bill grows the way it does.

## What that costs, with the arithmetic spelled out

Do the addition on that chart. Five requests: 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×** the size of the
       thing you built. At Claude Opus 5's $5 per million input tokens that run
       costs about **$0.05**, five cents, and nobody notices.

Now make one tool fat. A `read_file` that returns an 8k-token
       source file on turn 2 of a ten-turn agent gets resent on the eight
       requests that follow: 8 × 8k = **+64k input tokens**,
       $0.32, from one tool result. Run that agent a thousand times a day and
       the single `read_file` you never trimmed costs
       **$320 a day**. Ouch.

We hit this in the harness too. Every step it takes resends the whole
       array, so the setting that earns its place in the panel is *max
       steps*, **40** by default. It's the ceiling on how many
       more times one fat tool result gets billed.

Trim the tool result, not the prompt. The prompt is sent once.
      The tool result is sent on every turn that comes after it.

Two fixes, and you can use both. Return less: line ranges instead of
       whole files, counts instead of dumps, the twenty matching rows instead of
       the table. And cache the 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. Two requests over the same prefix and you're already ahead.
       That's lesson 5's whole subject. The number is here so you know why it
       gets a lesson.

## Four rules that will save you a weekend

- **Answer every call, including the failures.** If your
          function throws, don't drop the result. Send it back as a`tool_result` with`is_error: true` and the
          message in the content. The model reads errors and retries
          sensibly. A missing result is a protocol violation. A returned error
          is just information.
- **Return all results in one message.** The model may ask
          for three tools at once. Run them, then send all three`tool_result` blocks in a*single* user message.
          Splitting them across messages quietly teaches the model to stop
          asking in parallel, and your agent gets slower for no visible
          reason.
- **Append the reply whole.** Push`reply.content` , not a string you rebuilt from it. On
          current models the reply carries blocks besides text, and dropping
          them costs you quality on the next turn with no error to point
          at.
- **Cap the loop.**`while (true)` is fine in a
          lesson. In anything real, count the turns and stop at twenty. A
          model that has misread a tool description will happily call it forty
          times, and you'd rather find that out from a counter than from an
          invoice.

## The trade-off: your loop vs. the SDK's

Now that you have written the loop by hand, stop writing it. Both SDKs
       run it for you, `client.beta.messages.tool_runner()` in
       Python with the `@beta_tool` decorator,
       `client.beta.messages.toolRunner()` in TypeScript with
       `betaZodTool`. Ten lines instead of twenty, and the four rules
       above come for free.

Here is what you give up. The runner owns the control flow, so anything
       you want *between* turns, an approval gate before a write, a log
       line per call, a retry with a rewritten argument, a turn counter you own,
       goes through its per-turn hooks instead of a line you drop into your
       own `while`. That's a fair trade in a real project. It's a bad
       trade while you're learning. Write the loop yourself exactly once, which
       is now, so the runner never surprises you.

Straight about what we run: the harness in the next tab doesn't use a
       tool runner. It has the loop from this lesson written out by hand in
       `js/agent.js`, because it runs in a browser tab against
       `fetch` with no dependencies at all. Read it after the
       exercise. It's the same four stations, plus a step counter and a settings
       panel.

The same four stations show up everywhere, under different names:

- **Python** ,`anthropic` ,`tool_runner` +`@beta_tool` .
- **TypeScript / JavaScript** ,`@anthropic-ai/sdk` ,`toolRunner` +`betaZodTool` . Vercel's AI SDK wraps the same loop as`generateText({ tools })` .
- **Go, Java, Ruby, C#, PHP** , official Anthropic SDKs,
          each with a tool-runner entry point.
- **Local models** , Ollama and llama.cpp speak
          OpenAI-style`tools` . Different JSON, same ring.
- **MCP** , not another loop. It is a way to*get* tools, so someone else's server fills your`tools` array.

Pick whichever one your stack already uses. If you can point at the four stations in it, you can debug it.

## The exercise

Do this in [the harness](https://buttercup.sh/). It's 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:

- Turn a tool off in *TOOLS* and ask for it anyway. The model
          doesn't error. It improvises, and watching it improvise badly teaches
          you more about tool descriptions than any amount of reading.
- Ask for something no tool covers. See whether it says so or invents a plausible answer.
- In your own script, misspell `tool_use_id` . Read the error
          text. You'll meet it again.

## Safe to break

Nothing in that list can hurt anything. The harness 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. Break it on purpose while it's
       cheap.

## Next week

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

Twenty lines. Four stations. That's the whole distance between a model that writes about the weather and a program that goes and checks. Every tool you add from here is a thing your software can now do on someone's behalf, and choosing which ones deserve to be in that array is the actual craft.

Where this sits in the whole course, and what comes after:
       [the syllabus](2026-09-04-the-syllabus.html).

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