lesson 2: tool calling - free AI Agent training from buttercup.sh
Pangram verdict · v3.3
We believe that this entire text is AI.
AI likelihood · overall
AIArticle text · 1,679 words · 1 segments analyzed
15 September 2026 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 same lesson, walked through end to end: the loop written by hand, one trip round the ring, and what a mismatched tool_use_id looks like when it fails. Prefer to read? Everything in the video is below, and the code blocks are copyable. Watch on YouTube. 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: 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's 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'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: 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. The two lit lines are all it's actually held to: the name it must use, and the fact that it can't leave it out. Everything else on the left, the description above all, is a prompt rather than documentation. You'll spend more time editing that one sentence than editing the loop. 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 # 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. 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 send the array again. Mismatch the ids or skip one and the request is rejected. Of the errors you hit this week, that's the kind to hope for. It fails loudly. 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. request 1 0.3k request 2 0.9k request 3 2.0k request 4 2.9k request 5 4.2k One agent run, five requests. The hatched part of each bar is the conversation you already sent; the solid part is the only new thing in it. Nothing here is a leak or a mistake. This is what stateless means. It's also why tool output that comes back fat (a whole file, a whole page of JSON) costs you on every remaining turn, not just the one that asked for it. 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. one fat tool_result · a ten-turn runturn 2 · read_filereturns the whole file, 8k tokens, and the array keeps it 34 56 78 910 +64k input tokens · $0.32 · per run Eight blocks, one mistake. Each is a later request carrying that same 8k again, because you send the whole array every time and nothing ever takes the file back out of it. The bar underneath steps up once per request, and it steps rather than slides for a reason: the bill grows in whole tool results, so the cheapest edit you'll ever make is the one that decides how much comes back at station 3. Return the twelve lines you needed and seven of these eight blocks go away. Trim the tool result, not the prompt. The prompt is sent once. The tool result is sent on every turn that comes after it.