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.
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:
tool_use blocktool_result
carrying the same idThe 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:
{
name: "get_weather",
description:
"Current weather for one city.",
input_schema: {
type: "object",
properties: { city: { type: "string" } }, required: ["city"] }
}
{"city": "Paris"}
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:
- user, Do I need an umbrella in Paris?
- assistant, tool_use
id: toolu_01A·get_weather·{"city": "Paris"} - user, tool_result
tool_use_id: toolu_01A· 18°C, light rain - assistant, Yes: light rain in Paris at 18°C. Take one.
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.
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.
8k tokens, and the array keeps it- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
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
- If your function throws, return a
tool_resultwithis_error: trueand the message in the content. The model can read the error and retry. A missing result violates the protocol; a returned error gives the model information it can use. - The model may ask for several tools at once. Run them, then return
every result in one message. Send the
tool_resultblocks in a single user message. Splitting them across messages discourages parallel calls and adds round trips. - Append the full reply by pushing
reply.contentinstead of a string you rebuilt. On current models the reply carries blocks besides text, and dropping them can weaken the next response without producing an error. - Cap the loop.
while (true)works in a lesson. In production, count the turns and stop at a limit you chose. A model that misreads a tool description may keep calling it until your code intervenes.
ENOENT: notes/paris.md, the disk said no, and your
function raised- you drop the result
the array now holds a
tool_usewith nothing answering it, so the next request is the one that fails - you send it back
a
tool_resultwithis_error: trueand the error message as its content
read_filelist_dirgrep
- tool_result · toolu_01A · 412 bytes
- tool_result · toolu_01B · 9 entries
- tool_result · toolu_01C · 2 matches
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:
- Python:
anthropic,tool_runner+@beta_tool. - TypeScript and JavaScript:
@anthropic-ai/sdk,toolRunner+betaZodTool. Vercel's AI SDK wraps the same loop asgenerateText({ tools }). - Go, Java, Ruby, C#, and 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 supplies tools from another server. Your program still runs the loop.
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.
- user, make notes/paris.md with three lines about the weather, then read it back
- assistant, tool_use
write_file·{"path": "notes/paris.md", …} - user, tool_result wrote 3 lines, 87 bytes
- assistant, tool_use
read_file·{"path": "notes/paris.md"} - user, tool_result the three lines, back verbatim
- assistant, Written and read back. Here is what it says…
Then try these safe experiments:
- Turn a tool off in TOOLS and ask for it. The model doesn't raise a protocol error. It improvises. Its response will show how much the available tools and their descriptions shape its choices.
- 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.
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: