cd /news/ai-agents/how-ai-tool-calling-works-40-lines-o… · home topics ai-agents article
[ARTICLE · art-130607] src=buttercup.sh ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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

A tutorial demonstrates that tool calling — the mechanism that turns a chat model into an agent — requires roughly 20 lines of vanilla JavaScript using Anthropic's @anthropic-ai/sdk, in which the model emits a structured block naming a function and JSON arguments that the developer's own code executes. The lesson describes a four-station loop: the program sends the conversation plus a JSON Schema tool description, the model replies, the program appends the reply unchanged, and if stop_reason equals "tool_use" it runs each requested function and pastes the return value back into the conversation. The author notes the model never runs anything itself, and that the conversation array is the state and the loop is the program, with no memory, planning module, or framework required.

read11 min views2 publishedSep 15, 2026

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.

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.

import anthropic

client = anthropic.Anthropic()          # reads ANTHROPIC_API_KEY

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

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"],
    },
}]

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,
    )

    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 atool_result withis_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 threetool_result blocks in asingle 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. Pushreply.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 asgenerateText({ tools }) .
  • Go, Java, Ruby, C#, PHP , official Anthropic SDKs, each with a tool-runner entry point.
  • Local models , Ollama and llama.cpp speak OpenAI-styletools . Different JSON, same ring.
  • MCP , not another loop. It is a way toget tools, so someone else's server fills yourtools 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. 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.

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

── more in #ai-agents 4 stories · sorted by recency
── more on @anthropic 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-ai-tool-calling-…] indexed:0 read:11min 2026-09-15 ·