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:
tool_use blocktool_result
carrying the same idNotice 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:
{
name: "get_weather",
description:
"Current weather for one city.",
input_schema: {
type: "object",
properties: {
city: { type: "string" }
},
required: ["city"]
}
}
{"city": "Paris"}
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:
- 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 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
- Answer every call, including the failures. If your
function throws, do not drop the result — send it back as a
tool_resultwithis_error: trueand 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_resultblocks 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 would rather find that out from a counter than from an invoice.
read_filelist_dirgrep
- tool_result · toolu_01A · 412 bytes
- tool_result · toolu_01B · 9 entries
- tool_result · toolu_01C · 2 matches
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:
- Turn a tool off in TOOLS and ask for it anyway. The model does not 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 will meet it again.
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: