This is the page that changes how you think about agents, and it is mostly arithmetic.
Everything up to now has treated an iteration as free. It is not. Every iteration is a full API call carrying a conversation that keeps growing, and the shape of that growth is the reason projects #4 and #5 exist at all.
Part 1 Β· Where the money actually goes
Here are the real numbers from one three-iteration run β "roll 3d20, then put that many cookies in the jar" β before any caching was added.
| Iteration | Input tokens | What is in there |
|---|---|---|
| 1 | 2,375 | system prompt + 8 tool definitions + your sentence |
| 2 | 2,513 | β¦plus the dice request and its result |
| 3 | 2,654 | β¦plus the cookie request and its result |
| total | 7,542 in / 288 out | for one twelve-word question |
Seven and a half thousand input tokens to roll some dice and count some cookies.
Now look at why it is that much
Read the column again. Iteration 2 is not 138 tokens. It is 2,513 tokens β because it contains everything iteration 1 contained, plus a bit.
Which makes cost quadratic, not linear
If each iteration adds roughly the same amount of new material, then iteration
n costs roughly n units, and the run costs 1 + 2 + 3 + β¦ + n.
That sum is n(n+1)/2 β the area under a growing line, not its length.
| Iterations | Rough relative cost |
|---|---|
| 1 | 1Γ |
| 3 | 6Γ |
| 5 | 15Γ |
| 10 | 55Γ |
A five-step task does not cost five times a one-step task. It costs more.
Part 2 Β· One breakpoint, 47% off
Look at what is in those tokens. The system prompt and the eight tool definitions never change. Only the conversation on the end of them grows.
Most of that bill is the same bytes, three times.
Prompt caching lets you mark a prefix of the request as reusable. The API stores it; subsequent requests that begin with byte-identical content read from the cache instead of processing it again.
The loop puts a single cache_control breakpoint on the system block β which,
because the API renders tools β system β messages in that order, covers
the tool definitions too.
The same run, after:
| Iteration | Uncached input | Cache |
|---|---|---|
| 1 | 94 | write 2,281 |
| 2 | 231 | read 2,281 |
| 3 | 372 | read 2,281 |
| total | 697 | 2,281 written, 4,562 read at ~10% |
Same tokens sent either way. A cache read costs roughly a tenth of a fresh token, so the full-price equivalent drops from 7,540 to about 4,004.
Part 3 Β· The two ways to break it
Prompt caching is a prefix match. That single fact generates both failure modes.
Break #1 β anything variable in the prefix
Interpolate a timestamp, a session id, a username or a random request id into the system prompt, and every request has a different prefix. Every hit silently becomes a miss.
// β every request is a cache miss, forever
system: `You are a helpful assistant. The time is ${new Date().toISOString()}.`
// β
constant prefix; put the variable part in the messages
system: `You are a helpful assistant.`Break #2 β a server that shuffles its tool list
This one is not your fault, and you can still be the victim of it.
Your tool definitions sit near the front of every request. They come from
tools/list. If a server returns its tools in a different order on different
calls β because it iterated a hash map, or did a SELECT with no ORDER BY β
then your prefix changes and your cache breaks.
The specification calls this out directly:
Servers SHOULD return tools in a deterministic order [β¦] Deterministic ordering enables clients to reliably cache the tool list and improves LLM prompt cache hit rates when tools are included in model context.
Part 4 Β· Reading the meter correctly
This is the part that looks like a measurement bug the first time you see it.
Your input tokens fall from 2,375 to 94 and your instinct is that the counter is broken.
It isn't. input_tokens is only the uncached remainder β what you paid
full price for. The real prompt size is:
input_tokens + cache_creation_input_tokens + cache_read_input_tokens| Field | What it is | Priced at |
|---|---|---|
input_tokens | the part that was not cached | 1Γ |
cache_creation_input_tokens | the part written into the cache | ~1.25Γ |
cache_read_input_tokens | the part served from cache | ~0.1Γ |
Part 5 Β· Three seatbelts, and the one people skip
All in lib/agent-loop.ts:
MAX_ITERATIONS = 10
A hard stop that says so loudly rather than quietly spending money.
An unbounded tool-calling loop is not a hang β a normal runaway loop burns CPU you have already paid for; this one makes a paid API call every time round, with a conversation that is larger every time.
Explicit stop_reason handling
end_turn,max_tokens,refusal,pause_turneach get their own branch. A loop that only asks "is ittool_use?" is a loop that can spin on a case you did not think about.Token counts in the UI, per iteration
Partly a feature. Mostly a seatbelt.
This is the one people skip, and it is the one that makes the other two unnecessary β because you notice the shape of the curve long before you hit the cap.
You cannot manage a cost you cannot see.
By project #5 this has grown into a ledger with a row for every draw β including the refused ones, because a ledger with no refusals in it is indistinguishable from a gate that is switched off.
What you now know
- Every iteration re-sends the whole conversation, so cost is quadratic in iterations, not linear.
- One
cache_controlbreakpoint on the system block cut a real run's full-price input by 47%. - Caching is a prefix match: a timestamp in the system prompt, or a server that shuffles its tool list, silently disables it with no error.
input_tokensis only the uncached remainder β real size isinput + cache_creation + cache_read, and reporting one of the three invites a false comparison.- Cap the loop, branch on every
stop_reason, and put the meter on the screen.
Next: the twelve stages of building this, and the VERCEL_URL trap that
survives a green deployment.