Andrew Fribush

← All posts

Spending Limits for Autonomous Agents

· October 15, 2026 · 15 min read

Every model call an agent makes is a purchase, and the invoice arrives after the tokens have been generated. Post-hoc metering tells you on Thursday what a worker spent on Tuesday. A limit has to be checked before the call, so in the agent platform I built and ran, the check sat in front of the call. A proxy priced each request at the dearest tier, held the estimate against every budget the worker sat under, and answered HTTP 402 when it didn't fit. The kernel is five files and 2,755 lines of TypeScript and SQL on Postgres, and it ran against real Anthropic streaming traffic.

A worker box held one credential that looked like an Anthropic key. It was a proxy token, and every model call landed on a proxy. The proxy resolved the token to a worker, a tenant and an account, and then to the operator token that had minted the worker. Its estimate for the call counted the holds of calls still in flight, and a refused call never reached Anthropic.

An agent that can call a model can spend money, and the only ceiling that holds is one enforced before the upstream call. The proxy authenticates the caller, pre-authorizes the estimate, and refuses with a 402 before any cost exists. After the call, it meters the real response at the wire and settles. The part nothing mainstream ships is delegation: an agent mints a child credential whose budget is a subset of its own, with spend attributed up the lineage and revocation cascading down it.

The five files, with their tests and migrations, are the proxy, the ledger, the token tree, the grant grammar, and the file that puts a non-model action through the same gate. They ran in production as metered pre-authorization on my own account.

The platform around these files had 531 commits and 104 database migrations. By the second audit's count it came to 34,674 lines of production code in the server and the worker image, with 823 passing tests. On 2026-08-12 I had two independent Codex audits read the repository, one at 2.16 million tokens and one at 1.59 million. The first named the spending kernel as the only part that could leave the project and retain its value. The second named the same slice, agent-minted child credentials with lineage budgets and cascading revocation, as the repository's distinctive part. I kept the kernel and cut the rest the same night. The five files are 7.9% of that count, and their test siblings are 1,778 lines. The figures below replay synthetic amounts, with the rules and denial strings copied from the files.

402 Before Spend

The header comment on the proxy lists what it does, in order. It authenticates the proxy token to a worker, tenant and account, and it pre-authorizes against the wallet and budgets, rejecting with 402 before any upstream cost. It then forwards to Anthropic with the real platform key, streaming through. Last, it meters the response at the wire as a cost event with source proxy, which the debit meter draws from the wallet and the budget cron counts.

For a box at runtime tier 1 or above, the platform key never leaves the proxy: the box has no other platform-keyed route to Anthropic, and the ingest route doesn't accept a cost that such a box reports about itself. For that box, the budget counts only cost rows the platform wrote: the proxy's, its own infrastructure meters' and the actions gate's.

The estimate is deliberately high. estimateCostUcents prices the request at the highest tier in the price table and charges the input at the cache-write rate. It counts input tokens as the body length divided by 3 (the real ratio is nearer 4), and takes output as the request's max_tokens, defaulting to 4,096. A worker that names a cheap model in its request body can't under-reserve, because the claimed model is never priced from. Only the model named in the response is priced, and a request for a model with no price is refused outright, before the reservation and before the vendor is called.

The pre-authorization is one transaction, reserveAndAuthorize, under a for update lock on the account row. The account's weekly ceiling is checked first for every account, default 50,000 cents, as the week's committed spend from workers not yet retired, plus in-flight holds, plus this estimate. If the worker was created by an operator token, every budgeted ancestor of that token must have headroom for its whole subtree's weekly spend, holds included. Then the check branches on the account's billing mode. A prepaid account must have live balance minus holds to cover the estimate, and it also has a per-class weekly cap, checked after the fact. A postpaid account's worker must fit under its own weekly cap when it has one, counting the worker's own unsettled holds.

If every check passes, the estimate is added to the account's and the worker's reserved_ucents. A hold row is written with its own timestamp, and the hold's id travels with the call.

fig. 2.6.1 · one worker’s weekly cap over ten events: admitted with a hold, refused before upstream, settled.
CALL F OVER THE CAP, HOLDS INCLUDED: REFUSED BEFORE UPSTREAMONE BAR IS ONE WORKER’S 1,000¢ WEEKLY CAP: INK SETTLED, GREY HELD IN FLIGHT,ACCENT THE NEW CALL’S ESTIMATE.0CAP 1,000¢SETTLED 135¢IN FLIGHT 650¢THIS CALL 240¢CALL F · 135¢ SETTLED + 650¢ IN FLIGHT + 240¢ ESTIMATE = 1,025¢ > 1,000¢ CAP402 · THIS WORKER'S WEEKLY BUDGET CAP IS REACHED · NO UPSTREAM REQUEST, NO HOLDWORKERWORKERAUTHENTICATEAUTHENTICATEPRE-AUTHORIZEPRE-AUTHORIZEHOLDHOLDUPSTREAMUPSTREAMMETER AT THE WIREMETER AT THE WIRESETTLESETTLE402 · NO UPSTREAM CALL402 · NO UPSTREAM CALLEVENTS IN ORDER · ● A CALL ARRIVES · ○ A CALL SETTLES · DRAG THE AXIS TO REPLAYABACDBEFCFTHE SAME CHECK, IN THE SAME TRANSACTION, RUNS UNDER THE ACCOUNT’S WEEKLY CEILING,THE MINTING TOKEN’S LINEAGE BUDGET AND THE PREPAID BALANCE.

source: Mechanism from creelshed apps/web/server/model-proxy.ts: reserveAndAuthorize lines 103-205 (worker cap branch 154-165, hold written 176-183), estimateCostUcents 611-625, settle 629-665; amounts are a synthetic sequence

Without the holds, two calls from one worker arriving together each read the same remaining budget and each pass. The cap is then exceeded by one call's worth, which the first version, a snapshot check with no reservation, let happen. The test that pins the fix starts a prepaid wallet at 3,000,000 microcents. It fires three calls at once, each with a max_tokens of 1,000. The test passes only if at least one of them gets a 402 and the wallet ends non-negative. At settle the proxy meters the response and debits the wire-metered amount. Then it releases the hold by its id. Releasing by (account, worker, amount) would have matched a different, newer hold with the same triple and hidden a leak from the reaper.

Streaming is the hard case, because the output usage arrives only in the last frame. The proxy tees the upstream body. One branch streams to the client untouched, and the other is read to the end to recover the model from message_start and the usage from message_delta. The moment message_start parses, the proxy pins a watermark to the hold row with the resolved model and the input-side token counts. If the instance dies mid-generation, the reaper settles the call from that watermark instead of recording an unknown loss, charging what the provider had reported by then; output generated after the watermark goes uncharged. If the watermark write itself fails and the instance then dies, the reaper charges nothing rather than a guess.

The reaper exists for the hold that never settles. A process can die between the reservation and the fetch, or after the vendor delivered tokens but before the metering write, and both leave the same row: a hold with no settlement. The reaper releases holds older than 20 minutes, twice the proxy's 10-minute upstream timeout. It marks them reaped and keeps the row, so a crash leaves a record.

The first version keyed staleness on one reserved_at stamp per account, which every new reservation refreshed. On a busy account the stamp never aged, the reaper never fired, and a leaked hold sat in reserved_ucents, returning 402s against spend that never happened. I found that on 2026-07-29 and replaced the stamp with one row per hold, each carrying its own age.

The Ledger Holds Integer Microcents in Expiring Lots

The wallet is one row per account in integer microcents, one cent being 1,000,000 of them. The unit comes from the infrastructure meters. The worker's default Fly machine bills at 0.000512 cents a second, and a per-second tick rounded to whole cents is zero. Fractional cents are converted exactly once, at the meter boundary, and every row after that is an integer, so no debit involves float arithmetic.

Money in is a lot: a grant, a top-up, a refund or an adjustment, with a consumed_ucents watermark and an optional expiry. A lot is live while it hasn't expired and its consumed amount is below its size. A debit locks the account row and selects the live lots ordered by creation. It takes from each lot in turn until the amount is covered, and it writes one negative debit row with the capability class and the worker that spent it.

The account's cached balance is updated in the same transaction, under the same lock, so concurrent debits, top-ups and expiries serialize per account and the cache never drifts from the lot sum between mutations. A lot that lapses before the daily sweep still sits in the cache until it is reaped, which is why pre-authorization recomputes the live balance from the unexpired lots instead of reading the cache. The ledger tests assert that the cache equals the sum recomputed from the lots after each mutation, and one of them pins the gap before the sweep.

fig. 2.6.3 · a prepaid wallet as stacked credit lots over 84 days: six debits, oldest lot first, and one reap.
OLDEST LOT FIRST; A LAPSED REMAINDER IS REAPED, NOT SPENTONE BAND IS ONE CREDIT LOT, OLDEST AT THE BOTTOM, THE TOP EDGE THE BALANCE;ACCENT: A LAPSED LOT’S REMAINDER.0¢500¢1,000¢1,500¢LOT 1 · GRANT 500¢LOT 2 · TOP-UP 1,000¢ · LAPSES DAY 70LOT 3 · TOP-UP 600¢BALANCE−120¢−200¢−100¢−300¢−900¢: THE LAST 700¢OF LOT 2, THEN 200¢ OFLOT 3−250¢REAPED:80¢ LEFTWHEN LOT1 LAPSEDDAY 01020304050607080AMOUNTS ARE INTEGER MICROCENTS (1¢ = 1,000,000 µ¢); THE ACCOUNT BALANCE IS A CACHEOF THE LIVE-LOT SUM, UPDATED IN THE SAME TRANSACTION UNDER THE ACCOUNT ROW LOCK.

source: Mechanism from creelshed apps/web/server/credit.ts: header invariants 4-19, debit() 498-559 (FIFO lot query 508-519), expireLapsed() 586-631; amounts and days are a synthetic sequence

A daily cron handles expiry. It finds the lapsed lots and marks each one fully consumed. For each it writes a negative expiry row for the remainder, and it drops that remainder from the cache. The cron takes the account lock first, in the same order a debit does. Lots-then-account against account-then-lots can deadlock when a lot lapses between the two crons' clocks. A warning goes out once, in the 14 days before a lot lapses, and the default lot lifetime is 365 days.

A debit the wallet can't cover is partial, and the shortfall is not forgiven. It's added to the account's debt_ucents with a class-attributed overdraft row, because the debt column has no class and a large overspend on a small wallet would otherwise slip under a per-class weekly cap. A later top-up settles the debt before any of it becomes balance. Settlement is idempotent on the provider's response id, so a retried metering write can't bill the same call twice.

A Child Token Can Only Narrow Its Parent

An operator token is a credential for the control plane, and any authenticated token that holds tokens:mint can mint a child with no human in the loop. The child's authority is at most the parent's along every dimension: scope (a read token mints only read children), grants, expiry (the child is clamped to the earlier of its requested expiry and the parent's), and depth. A lineage may be at most 8 tokens deep, root counted as 1, so a runaway loop minting child of child converges instead of growing a tree every revoke has to walk. The refusal names the cap and the observed depth in its details.

Grants use a closed grammar. The warning that shaped it came from a Codex review: never diff arbitrary JSONB. A grant has five keys, actions, workers, runtimes, admin and ceilings, and null grants means full authority for the token's scope. The gated action vocabulary is 37 strings, from workers:read and tasks:create through tokens:mint, secrets:write and models:tune. The reads an agent always needs to orient itself, me and capabilities, are never gated and aren't in the list. A standing schedule is recurring task-creation authority, so schedules:write is its own grant, close to tasks:create but separately revocable. A top-level actions key outside grants is refused rather than ignored, because ignoring it would mint a full-authority child from a typo.

grantsSubsetOf is, in the file's own words, the one place delegation authority is decided; the mint function enforces scope, expiry and depth around it. For each list dimension, a parent that restricts it requires the child to be a subset, and the child may not leave the dimension unrestricted. Admin is not inheritable without an admin parent. A ceiling the parent sets must be set by the child, at or below the parent's number. Here it is run against the WRITER token of figure 2.6.2 below, which holds three actions and a 1,500 cent weekly spend budget. The first call asks for one action under a 500 cent budget. The second is the figure's EDITOR, asking for 3,000 cents. The third asks for workers:retire, which WRITER doesn't hold, the refusal the figure's JANITOR gets under its own parent. The fourth asks for full authority. The output is a real run, with one long line wrapped:

ts
import { grantsSubsetOf } from "./operator-grants.ts";const writer = {  actions: ["tasks:read", "tasks:create", "tokens:mint"],  ceilings: { weekly_spend_budget_cents: 1500 },};console.log(grantsSubsetOf(  { actions: ["tasks:create"],    ceilings: { weekly_spend_budget_cents: 500 } },  writer,));console.log(grantsSubsetOf(  { actions: ["tasks:read", "tasks:create"],    ceilings: { weekly_spend_budget_cents: 3000 } },  writer,));console.log(grantsSubsetOf(  { actions: ["workers:retire"],    ceilings: { weekly_spend_budget_cents: 500 } },  writer,));console.log(grantsSubsetOf(null, writer));
text
{ ok: true }{  ok: false,  error: "grants.ceilings.weekly_spend_budget_cents: child must set    a value <= the parent's 1500"}{  ok: false,  error: `grants.actions: "workers:retire" exceeds the parent's grant`}{  ok: false,  error: 'grants: a scoped parent cannot mint a full-authority child'}

The grammar's eight tests need no database and ran in 87 ms, all passing. The lineage tests that need one cover the cascade, the mint-race guard (a child minted with authority captured before its parent was revoked), the expiry clamp and the depth cap.

fig. 2.6.2 · one tenant’s token lineage: granted actions and weekly budget per token, and the subtree a revoke takes.
A CHILD ONLY NARROWS ITS PARENT; A REVOKE TAKES THE SUBTREEONE ROW IS ONE TOKEN UNDER ITS PARENT; A DOT IS A GRANTED ACTION, THE BAR ITSWEEKLY BUDGET; ACCENT: REVOKED OR REFUSED. CLICK A NAME TO TRY ONE REVOKE; A NEWCLICK REPLACES IT.WORKERSTASKSTOKENSREADCREATERETIREREADCREATEMINTREVOKEROOTROOTWRITE · NEVER · NO BUDGETORCHESTRATORORCHESTRATORWRITE · 7 D5,000¢5,000¢RESEARCHERREVOKED, 1 DESCENDANT2,000¢2,000¢SCRAPERSCRAPERCASCADED500¢500¢WRITERWRITERWRITE · 7 D1,500¢1,500¢EDITORMINT REFUSED3,000¢ > 1,500¢AUDITORAUDITORREAD · NEVER · NO BUDGETJANITORMINT REFUSED"WORKERS:RETIRE" EXCEEDS PARENT’S GRANT

source: Rules from creelshed apps/web/server/operator-grants.ts (grantsSubsetOf 245-279, refusals 264 and 274, MAX_TOKEN_DEPTH 85) and operator-tokens.ts (mintChildOperatorToken 342-426, clampExpiry 449-453, revokeOperatorToken 536-580); tree shape from operator-tokens.test.ts 161-183; names and amounts illustrative

The budget on a token binds its whole subtree. weekly_spend_budget_cents is the maximum weekly trusted spend attributable to the token, model calls through the proxy, infrastructure meters and paid actions alike, across the workers created by it or by any token descended from it.

The proxy enforces it at pre-authorization. It walks up from the worker's creating token to collect every budgeted ancestor. For each one it sums the week's trusted spend across every worker created by a token in that ancestor's subtree, plus those workers' unsettled holds. Both recursive queries are tenant-filtered and bounded at depth 16, above the cap of 8, so a malformed parent id or a row cycle can't spin the 402 path. An agent reads its subtree's committed spend beside its budget on /me, instead of finding its budget by hitting a 402.

Revocation walks the subtree. Revoking a token sets revoked_at on it and on every descendant in one recursive update, so a leaked control-plane credential can be killed together with its children. A worker the revoked token already created keeps its own proxy token and stays under the lineage budget; retiring the worker is what de-authenticates it at the proxy. The race is a mint committing into a subtree that a concurrent revoke has already walked past. If the revoke ran first, the mint's re-check of its parent refuses, and if the mint ran first, the revoke's walk sees the child. To get that ordering, mint and revoke serialize on a transaction-scoped advisory lock keyed on the lineage root, and the mint does its re-check that the parent is still live inside that lock.

The same gate governs actions that aren't model calls. The platform's first paid action was a web search at a flat price, and actions.ts runs it through the same sequence: reserve and authorize, run the action, meter it as the actions class, debit, release. A denied reservation never runs the search, and a provider failure never charges.

Isn't This Just a Rate Limiter With a Wallet?

A rate limiter limits how fast calls arrive. The 402 gate checks whether this call, at its estimated cost, fits under every budget the caller sits under, before the cost exists. Google added project spend caps to the Gemini API in March 2026, after a stolen key took a three-person team from $180 a month to $82,314.44 in 48 hours. The caps apply with a delay of about 10 minutes, with the overage in that window the customer's.

Spend is attributed by lineage. Every cost event carries the worker, and every worker carries the operator token that created it, so a week's spend can be summed for any token over every worker under it or its descendants. A per-key budget sums one key. The proxy computes the subtree sum inside reserveAndAuthorize, under the account lock, before it places the hold.

Revocation cascades. Rotating a vendor key stops every caller at once. A confirm dialog or a trained refusal is a policy the agent is asked to follow, and OpenAI's Operator bought $31.43 of eggs in February 2025 under a stated safeguard requiring confirmation before purchases. Both the budget check and the revocation run in Postgres, outside the model's instructions. Revoking a token stops that token and its descendants, through one recursive update that sets revoked_at down the subtree.

LiteLLM's proxy, checked on 2026-09-23 and again on 2026-10-13, documents per-key budgets (max_budget), key expiry (duration), per-key route restrictions and per-key blocking. The page describes no parent-key to child-key lineage, no sub-budget inherited from a creating key, and no cascading revocation.

What Was Proven and What Was Not

Proven in production, on one account, against real Anthropic streaming traffic: the 402 gate and the wire-metered settlement, a worker going from hire to ready in about 7 minutes, and a sleep/wake schedule firing on time through an image roll. The second audit's generous upper bound on the lines one job needed was 16,457 of the 34,674, 47.5%, and it counted the model proxy and the credit ledger in full.

Never proven, by the platform around the kernel: a stranger onboarding (sign-in was hard-coded to my account, so it had zero external users), anyone paying (Stripe never left sandbox), any job worth paying for, or a second tenant (the digest picked the first). The platform is archived, and I destroyed the Fly app that ran the workers on 2026-08-12. Within the kernel, delegation depth and the mint-race guard are covered by database-backed tests that don't overlap transactions, not by load, and the 402 path was tested against one upstream. The prior-art check read LiteLLM's documentation and not Portkey's. The wire-metered settlement makes the charge exact after the call, and a cache read of a large pre-cached context isn't bounded by the body length, so a vendor-side capped key stayed the last ceiling behind the proxy.

I built the kernel because my own workers were about to spend real money on real Anthropic streams, and I wanted the ceiling to live in the database, outside any prompt. If your agents call a model through anything you control, put the check in front of the forward. Price the request at the dearest tier and hold the estimate under the account lock. Answer 402 when it doesn't fit. Settle from the usage the provider reports, and let a token mint only a token smaller than itself.

Andrew Fribush, software and computer engineer in Austin. I build consequential systems where software meets institutions: finance, healthcare, commerce, government data, and the mechanisms that govern AI agents. Taking new engagements: hello@andrewfribush.com.

I write when a system teaches me something. Get new posts by email: no tracking, one-click unsubscribe.