Something can now spend your money. You need a rule.
There are two tempting ones, and both are wrong in an instructive way. Working out why is how you arrive at the third.
Part 1 · Tempting design #1 — a budget
Give the run a spending limit. Subtract as you go. Stop at zero.
// ❌
budget -= cost;
if (budget <= 0) throw new Error("out of budget");It is worse than it looks, and the reason has nothing to do with arithmetic.
A budget is an accounting device. It tells you how much is left. It is not a place where a decision gets made — and pretending it is one means the most consequential moment in the run happens by accident.
Part 2 · Tempting design #2 — project #3's gate
Gate on what the call is. That worked beautifully for destruction:
smash_jar always asks, jar_history never does.
It works because destruction is a property of the verb. A tool that deletes things deletes things every time you call it. The name tells you, and reading the arguments covers the rest.
Cost is not like that. And project #5 can prove it with a single tool:
| the same tool, the same server, the same user question | estimate |
|---|---|
summarise_week { depth: "brief" } — reads the event log, small model | 0.22¢ |
summarise_week { depth: "deep" } — every jar's full report, bigger model | 6.0¢ |
Twenty-seven times the price, same name. A rule keyed to summarise_week would
have to stop both or allow both, and neither is what you want.
And reading the arguments does not rescue it either
You could write matches: (args) => args.depth === "deep". That works — for
exactly this server, on exactly this tool, until somebody adds a depth: "exhaustive", or the pantry grows from 60 jars to 600 and brief quietly
becomes expensive.
Part 3 · So the rule is a number
below the ceiling → runs immediately, logged to the ledger
above the ceiling → refused, with the price attachedTwo cents per request, per server, by default. Small enough that the demo trips it deliberately; large enough that an honest weekly summary of sixty jars flows through without anybody clicking anything.
Why per server, and not one global budget
Because a server is the unit of trust.
"The pantry may spend 2¢ a call, the kitchen 5¢, and that server I connected once and never audited: nothing."
That is a sentence you can actually reason about. One global budget cannot express it — and worse, under a single pot a compromised server eats everybody else's headroom before anybody notices.
The estimate is what makes a refusal free
This is the part that turned out to matter most, and it fell out of the design rather than being aimed at.
The gate reasons about an estimate, computed before the call. So when it refuses, the host has not contacted the model at all.
The most expensive thing a server can ask for costs exactly as much as the cheapest thing it can ask for: nothing.
Project #3's gate saved you from a destroyed cookie jar. This one saves you from the bill itself — and that asymmetry is the actual argument for gating on price rather than on which tool was called. A destructive-action gate that fires has already cost you the tokens of getting there. A price gate that fires has cost you nothing.
Part 4 · The gate you can unit-test
One property worth calling out, because it is a deliberate choice and it is why this control is actually exercised.
export function classifySpend(
request: SpendRequest,
context: SpendContext = {},
): SpendVerdictIt is not async. It does not read the database. It does not touch the network. Everything it needs is passed in.
Part 5 · Two more numbers the gate holds
A ceiling on its own is not enough, and both of these are project #4's seatbelts wearing new hats.
| Control | Value | Why |
|---|---|---|
| Output clamp | 1024 tokens | A server may ask for whatever maxTokens it likes. It gets this or less. maxTokens: 1_000_000 is a denial-of-wallet attack that arrives looking exactly like a JSON field. |
| Per-run quantity cap | 12 sampling requests | A per-call ceiling with no total is a machine for making a thousand cheap calls. Nineteen requests at 1.9¢ each, every one under a 2¢ ceiling, is 36¢ nobody approved. |
The quantity cap is checked first, before the price — because a server making its thirteenth cheap request is a runaway regardless of how cheap each one was.
Part 6 · Estimate high, bill honestly
One last detail, and it is the only file in the repo whose correctness expires.
lib/pricing.ts holds the price table. Sonnet 5 was on an introductory rate
with an end date. Hard-code the discounted number and every downstream
calculation reports to two decimal places, with total confidence, and is wrong
from the day the discount ends.
So: encode both rates and the date the intro ends, and stamp
PRICES_CHECKED_ON where the UI and the checkpoint scripts print it.
Now for the part that took longest to get right: this gate cannot do the one thing project #3's gate was built around.
What you now know
- A budget truncates at an arbitrary point; it is a backstop, not a decision.
- Destruction is a property of the verb; cost is not. The same tool is 0.22¢ or 6.0¢.
- Matching on arguments only correlates with cost — the consequence has to be computed.
- Ceilings are per server, because a server is the unit of trust and a shared pot lets one server eat everyone's headroom.
- Gating on the estimate is what makes a refusal cost
$0.00. - Keep the gate synchronous and dependency-free so it can actually be tested.
- Clamp output, cap quantity, and make a price table's expiry date part of the value.