Learning map / Task 1
Build an agent from scratch, then make it ask the model less
One order desk, one question, four versions of the program that answers it, each cheaper to run than the last. By the end you can state, from a trace (the written record of one run), what each request to the model costs and which change to the program removes it.
Reading: about 25 minutes, no programming needed. Building the four versions yourself, for readers who write Python: an afternoon and an API key (the password your program sends with each request).
- API
- software engineering
- production and scale
- Python
Four words, in everyday terms
You do not need to program to follow this page. Four words carry it; each points into the picture of who does what, which comes under the next heading but one:
- A model (here, Claude) is a program that reads text and writes text back. On its own it cannot open a file, search a database or look anything up. In the picture: the right-hand lane, under the struck-through hand.
- An API (application programming interface) is how one program asks another for something over the internet: it sends a request and gets a reply. The server code, the program a developer wrote (a server is a computer that runs a program for other people), talks to Claude through the API of Anthropic, the company that makes Claude. In the picture: every arrow crossing the edge of Anthropic's side, both ways.
- A tool is a job the server code offers to do for the model, described in plain words, such as "get_orders: look up a customer's open orders". The model cannot run it. It can only ask, and the server code does the work . In the picture: the names on the dashed arrows; the box beneath is the server code running them.
- An agent is the server code and the model working in a loop: the model asks for tools, the server code runs them and reports back, and this repeats until the model can answer. In the picture: the middle and right-hand lanes together, from the first request to the answer.
The scaffold level is how much help this page shows, from worked code at 0 to nothing but the questions at 3.
The level fades what this page shows. The project's tests, automatic checks that run each version of the program and compare what it measures (the three costs the scenario below names) with the expected values, stay the same at every level. Move up when the previous level felt easy, not when it felt finished. Your choice is remembered in this browser only.
The scenario, toy to production
A support desk asks: "For customers C1 to C4, list open orders and the shipping status of each." The data lives in tasks/t01_fewer_tool_calls/store.py. Today it is three customers in a small lookup table in the code (a Python dictionary), and the question names a fourth. The same loop will be asked to do ten thousand customers nightly, and every extra request to the model then costs time and money, and a request that fails costs them again when it is sent again .
The data. Two tools do the work. get_orders takes a customer and returns that customer's open orders, the ones not delivered yet, each with an order number. get_status takes one order number and returns its shipping status, so no status can be asked for until the orders are known. Open orders per customer in the question:
| Customer | Open orders |
|---|---|
| C1 | 2 |
| C2 | 1 |
| C3 | 3 |
| C4 | not found |
So the question is 2 + 1 + 3 open orders, 6 statuses to look up. C4 is not a customer, so looking it up fails. The model does not know that until it asks, and in this lesson it never asks again: C4 does not exist, so a retry could not help.
Six more words, used on every line below:
- A round trip is one request your code (the server code: the program a developer, maybe you, writes; not the person at the desk) sends to the model and the one reply it gets back. The model cannot act between requests; everything it learns arrives in the next one.
- A tool call is one ask the model makes inside a reply (not a request: only what your code sends the model is a request), asking your code to run one tool once, such as "look up the orders of C1".
- Parallel tool use is one reply holding several tool calls at once.
- A tool result is your code's answer to one tool call. Your code sends one per call, in the next request.
- Tokens are the pieces of text you pay for: a word or part of a word each. Every count on this page comes from this lesson's stand-in for Claude, a scripted program that follows the documented tool-use rules (How to count, below); it counts characters divided by four, so a count here is an estimate of the shape, not a bill. The counts on this page are what is sent (input tokens); the replies' own tokens are billed too.
- A trace is the written record of one run: every request, reply and tool call in order, with its counts.
Three costs move separately: round trips (waiting, and the whole conversation so far, the transcript, re-read each time), tool calls (your code's work), and tokens .
One round trip, and why each one costs more than the last
A picture to hold on to. Think of the model as an expert you can reach only by phone. They cannot see your systems, and they forget each call the moment it ends. So every time you call, you read back the whole conversation from the start. Then they either give the answer or ask you to look things up: "check C1's orders, and C2's". You hang up, look them up, and call back with everything said so far plus what you found. Each call is a round trip. Each lookup they ask for is a tool call. The read-back is literal: the API keeps no memory between requests, so your code resends the whole conversation every time .
The conversation is a list of messages, each labeled with who sent it: assistant for the model's replies, and user for everything your code sends, tool results included . So a "user message" on this page is your code speaking, not the support-desk person.
- solid segment: the question and the tool list (with the request's wrapping), first sent in request 1
- dashed segment: reply 1 and its results, first sent in request 2
- dotted segment: reply 2 and its results, first sent in request 3
- a request to the model is one arrow: the earlier request's segments again as its tail, one new segment at the head; the longer the arrow, the more is sent, and every segment is paid for each time
- dashed arrow back: the model replies, naming the tools it wants run
- double arrow: you and the server code, outside the API
- box on a line: the server code working
- bracket: one round trip; all of them together are the agent's loop
- hatching: you, waiting
- the counts: tokens are counted as the scenario says, characters divided by four, an estimate of the shape, not a bill. These are input tokens, what is sent; the replies' own tokens are billed too and are not counted here. Every request is paid in full: the API can charge less for an opening it has seen before, and this run does not use that.
- You ask the server code your question once, then wait.
- The server code sends request 1 to the model: your question and the list of tools it may ask for.The server code sends request 1 to the model: your question and the list of tools it may ask for: 170 tokens for the question, the tool list and the request's wrapping, the field names around them. Tokens are counted as the scenario says, characters divided by four, an estimate of the shape, not a bill. These are input tokens, what is sent; the replies' own tokens are billed too and are not counted here. Every request is paid in full: the API can charge less for an opening it has seen before, and this run does not use that.
- The model cannot run a tool, so reply 1 asks the server code to run 4 lookups with
get_orders. A reply that asks for tools is labeledtool_use("run these for me"); the answer is labeledend_turn("I am done") . - The server code runs the 4 lookups and adds one result per call, all together in one message, to the message history it sends the model . C4 is not found, and that result goes back too. None of this is a round trip: nothing goes to the model.
- Request 2 sends everything again, plus reply 1 and its results.Request 2 sends everything again, plus reply 1 and its results: 170 (the question and the tool list) + 260 (reply 1 and its results) = 430 tokens.
- Reply 2 asks the server code to run one status lookup per open order, C1's 2 + C2's 1 + C3's 3 = 6, with
get_status. - The server code runs the 6 lookups; all are found.
- Request 3 sends everything again, plus reply 2 and its results.Request 3 sends everything again, plus reply 2 and its results: 170 (the question and the tool list) + 260 (reply 1 and its results) + 378 (reply 2 and its results) = 808 tokens.
- Reply 3 asks for no tool. It is the answer, and the loop ends.
- The server code shows you the list, one line per customer, as the model wrote it. Behind it: 3 round trips and 10 tool calls.The server code shows you the list, one line per customer, as the model wrote it. Behind it: 3 round trips and 10 tool calls, and 1,408 tokens sent: 170 (request 1) + 430 (request 2) + 808 (request 3), every request paid in full.
The exact messages, from the same run the tests measure: what each request carried, what each reply said, and how each result went back. To read them: a toolu_ label is the ticket that pairs a result with the call it answers; is_error marks a lookup that failed; the input schema is the form a tool's inputs must take, sent with its name and description; the limits are the most tokens a reply may use. The braces, brackets and quoted names are JSON, the notation the API speaks, and every character of it is counted. stop_reason is the label the loop reads: tool_use means run these, end_turn means done; each call block is typed tool_use as well.
| part | request 1 | request 2 | request 3 |
|---|---|---|---|
| the question | 173 | 173 | 173 |
| reply 1 | 462 | 462 | |
| the results of reply 1 | 576 | 576 | |
| reply 2 | 688 | ||
| the results of reply 2 | 819 | ||
| the tool list | 432 | 432 | 432 |
| the rest of the request: the model name, the limits, the field names, the commas between messages | 75 | 79 | 83 |
| characters | 680 | 1,722 | 3,233 |
| input tokens (divided by four, rounded down) | 170 | 430 | 808 |
| new in this request: its tokens minus the request before, so the commas and rounding land here | 170 | 260 | 378 |
Step 1, exactly: the question, before any request
Not a message to the API. You type the question; the server code builds request 1 from it. the question: "For customers C1, C2, C3 and C4: list each customer's open orders, then the shipping status of every order. Finish with one line per customer."
Step 2, exactly: request 1
user message (173 characters as sent, its labels and quotes included): text: "For customers C1, C2, C3 and C4: list each customer's open orders, then the shipping status of every order. Finish with one line per customer." tools offered (432 characters, the input schema of each included): get_orders: "Open orders for one customer." get_status: "Shipping status of one order." the rest of the request (75 characters): the model name, the limits, the field names and the commas between messages characters: 173 + 432 + 75 = 680; divided by four, rounded down: 170 input tokens
Step 3, exactly: reply 1
stop_reason: tool_use
tool_use toolu_0001_get_orders: get_orders({"customer_id":"C1"})
tool_use toolu_0002_get_orders: get_orders({"customer_id":"C2"})
tool_use toolu_0003_get_orders: get_orders({"customer_id":"C3"})
tool_use toolu_0004_get_orders: get_orders({"customer_id":"C4"})Step 4, exactly: the results of reply 1, as request 2 carries them
sent back in request 2, one tool_result per call:
tool_result for toolu_0001_get_orders: "{"customer_id": "C1", "orders": ["ord-101", "ord-102"]}"
tool_result for toolu_0002_get_orders: "{"customer_id": "C2", "orders": ["ord-201"]}"
tool_result for toolu_0003_get_orders: "{"customer_id": "C3", "orders": ["ord-301", "ord-302", "ord-303"]}"
tool_result for toolu_0004_get_orders is_error=true: "'C4' not found"Step 5, exactly: request 2
user message (173 characters as sent, its labels and quotes included):
text: "For customers C1, C2, C3 and C4: list each customer's open orders, then the shipping status of every order. Finish with one line per customer."
assistant message (462 characters as sent, its labels and quotes included):
tool_use toolu_0001_get_orders: get_orders({"customer_id":"C1"})
tool_use toolu_0002_get_orders: get_orders({"customer_id":"C2"})
tool_use toolu_0003_get_orders: get_orders({"customer_id":"C3"})
tool_use toolu_0004_get_orders: get_orders({"customer_id":"C4"})
user message (576 characters as sent, its labels and quotes included):
tool_result for toolu_0001_get_orders: "{"customer_id": "C1", "orders": ["ord-101", "ord-102"]}"
tool_result for toolu_0002_get_orders: "{"customer_id": "C2", "orders": ["ord-201"]}"
tool_result for toolu_0003_get_orders: "{"customer_id": "C3", "orders": ["ord-301", "ord-302", "ord-303"]}"
tool_result for toolu_0004_get_orders is_error=true: "'C4' not found"
tools offered (432 characters, the input schema of each included):
get_orders: "Open orders for one customer."
get_status: "Shipping status of one order."
the rest of the request (79 characters): the model name, the limits, the field names and the commas between messages
characters: 173 + 462 + 576 + 432 + 79 = 1,722; divided by four, rounded down: 430 input tokens
the figure splits the same 430 by the request that first sent each part: 170 + 260, where each new part is this request's count minus the one beforeStep 6, exactly: reply 2
stop_reason: tool_use
tool_use toolu_0005_get_status: get_status({"order_id":"ord-101"})
tool_use toolu_0006_get_status: get_status({"order_id":"ord-102"})
tool_use toolu_0007_get_status: get_status({"order_id":"ord-201"})
tool_use toolu_0008_get_status: get_status({"order_id":"ord-301"})
tool_use toolu_0009_get_status: get_status({"order_id":"ord-302"})
tool_use toolu_0010_get_status: get_status({"order_id":"ord-303"})Step 7, exactly: the results of reply 2, as request 3 carries them
sent back in request 3, one tool_result per call:
tool_result for toolu_0005_get_status: "{"order_id": "ord-101", "status": "shipped"}"
tool_result for toolu_0006_get_status: "{"order_id": "ord-102", "status": "packed"}"
tool_result for toolu_0007_get_status: "{"order_id": "ord-201", "status": "shipped"}"
tool_result for toolu_0008_get_status: "{"order_id": "ord-301", "status": "delayed"}"
tool_result for toolu_0009_get_status: "{"order_id": "ord-302", "status": "shipped"}"
tool_result for toolu_0010_get_status: "{"order_id": "ord-303", "status": "packed"}"Step 8, exactly: request 3
user message (173 characters as sent, its labels and quotes included):
text: "For customers C1, C2, C3 and C4: list each customer's open orders, then the shipping status of every order. Finish with one line per customer."
assistant message (462 characters as sent, its labels and quotes included):
tool_use toolu_0001_get_orders: get_orders({"customer_id":"C1"})
tool_use toolu_0002_get_orders: get_orders({"customer_id":"C2"})
tool_use toolu_0003_get_orders: get_orders({"customer_id":"C3"})
tool_use toolu_0004_get_orders: get_orders({"customer_id":"C4"})
user message (576 characters as sent, its labels and quotes included):
tool_result for toolu_0001_get_orders: "{"customer_id": "C1", "orders": ["ord-101", "ord-102"]}"
tool_result for toolu_0002_get_orders: "{"customer_id": "C2", "orders": ["ord-201"]}"
tool_result for toolu_0003_get_orders: "{"customer_id": "C3", "orders": ["ord-301", "ord-302", "ord-303"]}"
tool_result for toolu_0004_get_orders is_error=true: "'C4' not found"
assistant message (688 characters as sent, its labels and quotes included):
tool_use toolu_0005_get_status: get_status({"order_id":"ord-101"})
tool_use toolu_0006_get_status: get_status({"order_id":"ord-102"})
tool_use toolu_0007_get_status: get_status({"order_id":"ord-201"})
tool_use toolu_0008_get_status: get_status({"order_id":"ord-301"})
tool_use toolu_0009_get_status: get_status({"order_id":"ord-302"})
tool_use toolu_0010_get_status: get_status({"order_id":"ord-303"})
user message (819 characters as sent, its labels and quotes included):
tool_result for toolu_0005_get_status: "{"order_id": "ord-101", "status": "shipped"}"
tool_result for toolu_0006_get_status: "{"order_id": "ord-102", "status": "packed"}"
tool_result for toolu_0007_get_status: "{"order_id": "ord-201", "status": "shipped"}"
tool_result for toolu_0008_get_status: "{"order_id": "ord-301", "status": "delayed"}"
tool_result for toolu_0009_get_status: "{"order_id": "ord-302", "status": "shipped"}"
tool_result for toolu_0010_get_status: "{"order_id": "ord-303", "status": "packed"}"
tools offered (432 characters, the input schema of each included):
get_orders: "Open orders for one customer."
get_status: "Shipping status of one order."
the rest of the request (83 characters): the model name, the limits, the field names and the commas between messages
characters: 173 + 462 + 576 + 688 + 819 + 432 + 83 = 3,233; divided by four, rounded down: 808 input tokens
the figure splits the same 808 by the request that first sent each part: 170 + 260 + 378, where each new part is this request's count minus the one beforeStep 9, exactly: the final reply, the answer
stop_reason: end_turn text: "C1: 2 open orders (ord-101 shipped, ord-102 packed) C2: 1 open orders (ord-201 shipped) C3: 3 open orders (ord-301 delayed, ord-302 shipped, ord-303 packed) C4: could not be found"
Step 10, exactly: the list, after the last reply
Not a message to the API. The server code prints the final reply's text; that is the list you see. the answer: "C1: 2 open orders (ord-101 shipped, ord-102 packed) C2: 1 open orders (ord-201 shipped) C3: 3 open orders (ord-301 delayed, ord-302 shipped, ord-303 packed) C4: could not be found"
Go deeper: what a reply and the next request actually contain
The model's reply is a list of blocks. Here is a reply asking for two lookups at once, which is parallel tool use:
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "toolu_1", "name": "get_orders", "input": {"customer_id": "C1"}},
{"type": "tool_use", "id": "toolu_2", "name": "get_orders", "input": {"customer_id": "C2"}}
]
Your code's next request repeats everything so far, then adds one user message holding a result for each call, matched by id. The results must come straight after the reply that asked for them, and before any other text, or the API rejects the request .
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "[\"ord-101\", \"ord-102\"]"},
{"type": "tool_result", "tool_use_id": "toolu_2", "content": "[\"ord-201\"]"}
]}
If a lookup fails, its result is still sent, marked "is_error": true, so the model knows the tool failed rather than returned data. Claude then works the error into its answer, or retries with a corrected request when its input was wrong .
Go deeper: what each of the three costs actually is
| Cost | Paid in | Grows with | Lowered by |
|---|---|---|---|
| Round trips | Waiting: roughly a second before each reply starts, plus the time to write it | How many times the model must wait for results before it can ask again | Asking in parallel; tools shaped like the task |
| Tokens | Money: every token sent in each request, the resent conversation and every tool result included | The length of the conversation, paid again on every round trip | Fewer, smaller results; prompt caching, which bills a repeated opening of the request at about a tenth of the normal input price on most models |
| Tool calls | Your own systems' time and load, plus their text in the conversation | How many lookups the model asks for | Tools that take a whole list; tools that do a whole job |
The one-second figure is an independent measurement and an order of magnitude, not a constant; it moves with the model and the load. Across ten thousand customers a night, run one after another, every extra round trip per customer adds hours.
What you need to predict
Each order then needs its status. The statuses cannot be asked for before the orders are known, so the work has two stages: orders first, statuses second. The list-taking tools some versions use (listed below) report C4 as "not found" inside a result that still answers the other customers.
The tools each version offers the model. A rung is one version of the agent; each rung changes one thing.
| Rung | Tools it offers the model | How the results go back | How the tool reports the failure |
|---|---|---|---|
| v0 | get_orders (one customer per call) and get_status (one order per call) | Each result in its own user message | As plain text, "Error: ..." |
| v1 | The same two tools | All of a reply's results together in one user message | Marked as an error, is_error |
| v2 | get_orders_batch and get_statuses_batch, batch tools: each takes a whole list in one call | Together | "not found" inside a result that still answers the other customers |
| v3 | open_orders_with_status, one tool that does both stages for a list of customers | Together | "not found" inside the result |
v0's separate messages still travel together in the next request, and the API accepts them: it combines back-to-back user messages into a single turn . So the split adds no round trip by itself. What it changes, according to the parallel tool use docs, is the model's later replies .
How to count. Count every tool call, including the one that fails. Count every round trip, including the last one, in which the model writes the answer and calls no tool. The tests do not use a real model: a scripted model stands in for it and follows the documented rules of tool use, so the counts are the same on every run. It starts out parallel: each reply asks for every call whose inputs it already knows.
The worked rung: v1, drawn from its run
Time runs down the page. The brackets under each request say what it carries and what that costs.
What each mark means
Messages, on the request arrow
- square corners: a message from your code (the API calls it a
usermessage) - rounded corners: a message from the model (the API calls it an
assistantmessage) - the same cards, narrower, under a bracket: messages sent again in a later request, paid for again; the bracket says what they are and what they cost
Tool calls, chips on the reply arrow, numbered #1, #2, ... in order. A reply that asks for tool calls has the stop reason tool_use ("run these and come back"); the answer has end_turn ("done")
- solid outline: a stage 1 call, orders
- dashed outline: a stage 2 call, statuses
- thick outline: one call that does both stages
Results, cards between the arrows, from your code
- tick: a result with data; on the answer card, a right answer
- cross and a hatched band: a failure the model can see, marked
is_error, or named inside a list tool's data (drawn inside the tick card of the result that names it); on the answer card, an answer that is wrong - question mark and a dotted outline: a failure sent as plain text, not marked
is_error, which this lesson's scripted model cannot read
get_orders(C4), fails when your code runs it, goes back at the top of round trip 2 marked ✗ is_error, and counts all the same.To predict the other three rungs, start from this picture and ask what the one thing each rung changes (the rung table above) does to the chips in each reply, and to the number of replies.
Hint for v0. The model's first reply is parallel, as always. Claude's docs say results split across several messages teach the model to avoid parallel calls . This lesson's scripted model takes that to the limit: once the results of a parallel reply come back split, it asks for one thing per reply from its next reply on. Predict v1 first, then ask what that rule changes for v0 after its first reply.
Predict before you run
Rungs 0 to 3 have worked versions under worked/; rung 4 has none. Work each one out from what the page states, write your numbers, then reveal. A wrong prediction is still the useful outcome: it names the rule you did not yet hold, and the reveal draws every rung the way v1 is drawn above, so you can see where your count and the measured one part ways.
| Rung | Your round trips | Your tool calls | Measured |
|---|
For readers who program: type these in a terminal in the project folder. You do not need them to follow the page; the reveal shows the same numbers.
make install make test # prints every rung's trace with -s; the numbers come from rungs.json make practice # your target; red until agent.py meets it
The same marks as the worked v1 diagram above. v0 is first: it is the one that surprises.
model.js, trace.js), which the tests hold to the Python run on the task's case.v0: 8 round trips, 10 tool calls
In plain terms. The model asked for four lookups at once. v0 handed the four answers back as four separate notes instead of one. The docs name this as the most common tool-use mistake: a separate user message for each tool result "teaches" Claude to avoid parallel calls. The fix is all the results together in a single user message . This lesson's stand-in for Claude turns "avoid" into a hard rule, so from then on it asked for one status per round trip: six round trips for six orders.
Round trip by round trip, as the v0 diagram above draws it ():
- Round trip 1: the model asks for the orders of C1, C2, C3 and C4 in one reply, 4 tool calls. v0 runs them and puts the four results in four separate user messages.
- Round trips 2 to 7: the model now asks for one status per reply,
ord-101first andord-303last. 6 tool calls, 6 round trips. - Round trip 8: the model has everything and writes the answer. No tool call.
Total: 1 + 6 + 1 = 8 round trips, and 4 + 6 = 10 tool calls.
And C4. v0 sends C4's failure back as ordinary text, "Error: ...", with no error flag. The documented way to report a failure is a result marked is_error . The docs do not say what a real model does with an unflagged error. This lesson's scripted model treats it as unreadable data, so v0's answer gets C4 wrong: "C4 wrong" in the Measured column, and on the answer card at the bottom of the v0 diagram. That is this lesson's rule, not documented behavior; a live run (make live) shows what a real model does.
Go deeper: the one line that costs six round trips
In worked/v0_naive.py the code appends a new user message inside the loop over results, once per result. worked/v1_parallel.py collects the results in a list and appends one user message after the loop. Same tools, same data; only where the append sits changed. Both versions send all four results in the same request, so the split adds no round trip on the spot. The cost comes later, from what it teaches the model about its next replies.
v1: 3 round trips, 10 tool calls
In plain terms. The model asks for everything it can in each round trip. It still needs two before it can answer, because it cannot ask for a status until it knows the order number, and it learns the order numbers only from the first round trip's results. A third round trip carries the answer.
- Round trip 1: four order lookups in one reply, 4 tool calls. All four results go back together; C4's is marked
is_error. - Round trip 2: six status lookups in one reply, one per order, 6 tool calls.
- Round trip 3: the answer.
Why three and not fewer. The work has two stages, and the second depends on the first (the stage figure above). Parallel tool use is on by default, so each stage fits in one reply , but no reply can ask for a stage-2 lookup before stage 1's results exist. Why ten tool calls. One per customer, the failing C4 included, plus one per order: 4 + 6.
Go deeper: what if parallel calls were switched off?
A developer can turn parallel tool use off . Then every reply holds at most one tool call, and v1 would need one round trip per call plus the answer: 10 + 1 = 11. v0 lands at 8, between the two, because its first reply is still parallel and only the later ones are not.
v2: 3 round trips, 2 tool calls
In plain terms. v2's tools take a whole list. So instead of four order lookups the model makes one ("orders for C1 to C4"), and instead of six status lookups it makes one ("statuses for these six orders"). Fewer, bigger lookups, but still the same two stages, so still three round trips.
- Round trip 1:
get_orders_batchfor C1 to C4, 1 tool call. C4 comes back as "not found" inside a result that answers the other three. - Round trip 2:
get_statuses_batchfor all six orders, 1 tool call. - Round trip 3: the answer.
What batching saved. The conversation now holds 2 calls and 2 results instead of 10 and 10, so each resent request is shorter and costs fewer tokens; this project's tests check that tokens fall with each rung. Your systems also answer two queries instead of ten. What it could not save. A round trip. Batching merges the calls inside a stage, never the stages themselves.
Go deeper: the cost of a big list
A list tool's result grows with the list. Anthropic's own agent tooling caps a tool response at 25,000 tokens by default and recommends paging, filtering or truncating large results, and returning only what the next step needs . Ten thousand customers in one call would need that.
v3: 2 round trips, 1 tool call
In plain terms. v3 offers one tool that does the whole job: "open orders with their status, for these customers". Your code looks up the orders and then their statuses itself, in an ordinary loop, and hands back the finished table. The model asks once, gets everything, and answers.
- Round trip 1:
open_orders_with_statusfor C1 to C4, 1 tool call. - Round trip 2: the answer.
Why. The dependency between the stages moved out of the conversation and into your code, where a loop costs no round trip. Anthropic's tool-design guidance recommends tools shaped like the task, for example one schedule_event tool instead of list_users, list_events and create_event .
Go deeper: what v3 gave up (the ladder's question, answered)
The sources recommend consolidating but do not list when not to, so this is reasoning, not a quoted rule. A tool shaped like one question fits only that question: "statuses only" or "orders placed this week" needs another tool or the old ones back. It also hides the in-between results from the model, which matters when a step needs judgment, such as deciding which delayed order to escalate. And its result grows with the whole question at once. Keep v2's two tools when the questions vary, when the model should reason between stages, or when other tasks reuse the small tools.
The ladder
The ladder is the hands-on part, for readers who write Python. If you do not program, everything it builds is explained in the answers after the reveal; skip ahead to the interview questions.
You build in tasks/t01_fewer_tool_calls/learner/agent.py, a copy of rung 0. Run make install once. make practice runs your file against rung 3's target and stays red until it meets it; make test prints every worked rung's trace to compare with.
Rung 0: the toy that works
worked/v0_naive.py. Read it until you can say, line by line, what the model receives. Then run it live once with python -m learnweb.live tasks.t01_fewer_tool_calls.worked.v0_naive (make live runs your own agent instead) and compare with the scripted trace. A live run calls the real API, so it needs ANTHROPIC_API_KEY in .env.
Watch for: the for block in response.content loop appends a user message per result; call_tool returns "Error: ..." as content.
Rung 1: the loop the docs describe
Same tools, correct contract: every result of a turn in one user message; failures flagged with is_error so the model can act on them .
Hint. Collect results in a list inside the turn; append once after the loop. Wrap the tool call in try/except KeyError and set is_error.
Worked: worked/v1_parallel.py and the shared worked/loop.py.
Rung 2: tools that take lists
Consolidate: one call per stage. Fewer tool calls, a shorter transcript, and a description that says when to use it .
Hint. v2 offers only the two list tools. If you keep get_orders alongside them while you build, the scripted model still picks the one whose schema has customer_ids, as a real model told "prefer this when more than one customer is involved" would.
Worked: worked/v2_batch.py.
Rung 3: one tool shaped like the question
The two stages happen in your code. The model makes one call and writes the answer . This is where the target lives: at most two round trips, C4 reported as not found.
Hint. A schema with customer_ids and include_status; store.open_orders_with_status already exists.
Worked: worked/v3_composite.py.
The question you must answer yourself: what did rung 3 give up? Name one situation in which you would keep rung 2's two tools on purpose.
Rung 4: production. Not built for you.
Four words the exit criteria use. A contract test pins each worked rung's measured round trips and tool calls (rungs.json), so a change that costs more fails. The system prompt is the standing instruction sent at the top of every request. A cache breakpoint marks the end of a prefix of the request that the API can reuse from its cache instead of processing again, which is cheaper and faster as long as that prefix does not change . The effort setting tells the model how many tokens to spend on a reply, trading thoroughness for cost and speed .
Exit criteria, each with its source. You add the rung; the contract tests stay green.
- A system prompt and tool list that never change between turns, with a cache breakpoint; prove it with
cache_read_input_tokens. - Stale tool results cleared from a long loop .
- Many lookups in a row run by code the model writes, which runs in a sandbox on Anthropic's side (programmatic tool calling). Your code still runs each tool, but the in-between results go back to that sandboxed code, never into the text the model reads, its context .
- An effort setting chosen by measurement, not default .
- An eval, a fixed set of test scenarios scored automatically, that reports round trips, tool calls and tokens per completed task across twenty scenarios .
Five-minute lessons
Where the round trips come from
Move the sliders, watch the turns. Find the setting where batch and composite cost the same round trips but different tokens.
Spot the bug
An agent loop with five documented mistakes. Click the lines. Wrong clicks stay on the board.
Teach it back
Write tasks/t01_fewer_tool_calls/learner/explain.md: what you changed, why each change moved which cost, and when you would not make it. Then make feedback (it needs ANTHROPIC_API_KEY, like make live). Every finding you get back cites the catalog (web/resources.json, the list of sources this page draws on) or is dropped and counted, so you can check each one yourself.
Interview questions this task prepares
- Walk me through one turn of a tool-use loop. What must the next request contain?
A model answer
Your code sends the whole conversation and the tool list, because the API keeps no memory between requests . If the reply's stop reason is
tool_use, it holds one or more tool calls . Your code runs each one. The next request is everything so far, the model's reply, then one user message with a tool result per call, matched by id, placed before any text, failures markedis_error. Repeat until the stop reason isend_turn. - Your agent makes forty calls for a task that needs four. Where do you look first?
A model answer
At a trace, counting round trips and tool calls separately, since they have different causes. Then four suspects, cheapest first. Results split across user messages, which teaches the model to stop calling in parallel . Tools that take one item when a list tool fits, as v1 against v2 shows. Failures not marked
is_error, the documented way to say a tool failed, or error messages that do not say what to try next . Tool descriptions that leave the model unsure which tool fits . - When is a coarse tool the wrong design?
A model answer
When the questions vary, because a tool shaped for one question needs a sibling for every variant. When the model should judge the in-between results, which a coarse tool hides. When its result would be too large to send whole . And when other tasks reuse the small tools. The guidance favors consolidating ; these exceptions are reasoning, so say so in the interview.
- What is the difference between fewer round trips and fewer tokens? Give a case where they move apart.
A model answer
Round trips count waits; tokens count text, which is what you pay for. v1 to v2 kept 3 round trips and cut tokens, because 2 calls and results replaced 10. The other way round: v3 has fewer round trips than v2, but if its single result carried every order's full history, tokens could rise. Prompt caching is a third case: it cuts token cost and leaves round trips alone .
- How would you measure whether a tool change helped?
A model answer
Run the same set of scenarios before and after, and record round trips, tool calls, tokens and whether the answer was right, per completed task rather than per request . This task's own tests do that for one scenario; a real check uses many more, twenty in the production rung, because one scenario can flatter a change.
- What does
is_errorchange about what the model does next?A model answer
It tells the model the tool failed, rather than returned data. The docs say Claude works the error into its answer, and retries with corrections when its own input was invalid; write the error so it says what went wrong and what to try next . The docs do not say what happens to an unflagged error, so a candid answer says that too.