Development

Caps on an Agent Finance Tool: Per Run, Customer and Day

Antonio Evans10 min read

A cutaway view of a single request path drawn as three stacked horizontal bands: a tool band at top, a gateway band in the middle, and a database band at the bottom.
Key points

Key points

  1. 1Put the hard stop where the money is recorded, inside the same database transaction that reserves and settles spend, so a retry loop cannot walk past the cap.
  2. 2Keep three counters: per run, per customer, and per day, each with an atomic conditional update rather than a read-then-write check.
  3. 3Use the API gateway for coarse per-customer rate limits and the tool for cheap pre-checks, but treat both as advisory because neither survives concurrency the way a row-level constraint does.
  4. 4Make every spend attempt idempotent with a client-generated key so a retried tool call settles once and returns the original result.

Why a cap needs a single point of truth

An agent finance tool has an unusual failure profile. The model can loop, the harness can retry, and the network between them can drop a response after the provider already charged the card. A timeout on a payment call does not mean the payment failed, it means the caller does not know what happened, and retrying is the only reasonable action 1. That is precisely the behavior a monetary cap has to survive.

If your cap lives in prompt instructions, a loop ignores it. If it lives in the agent framework, a retried tool call can re-enter before the first one finished. If it lives only in the gateway, the gateway sees request counts, not dollars committed inside a transaction. The only layer that can make a claim about money atomically is the one that writes the spend record, because it can check the budget and insert the debit in one round trip.

This is the same reasoning Stripe applies to charges: they make mutating endpoints idempotent with a client-supplied key so a repeated request returns the cached result of the successful operation rather than executing twice 5. A cap is a mutating operation in exactly this sense.

A second, quieter reason: agents escalate. NHIMG research cited in their write-access FAQ reports that 80% of organizations say their AI agents have already acted beyond intended scope, including unauthorized system access and credential exposure 11. Write capability to a payment rail is an actuation privilege. The cap is the blast radius control, not a nicety.

Three layers, three jobs

It helps to stop arguing about which layer should own the cap and instead assign each layer the job it is actually good at.

LayerGood atBad atVerdict
Tool code (inside the agent)Cheap rejection of obviously oversized requests, clear error messages back to the modelConcurrency, retries, multi-instance racesAdvisory pre-check
API gatewayPer-customer request rates, burst smoothing, coarse quotasDollars, per-run state, transactionalityAdvisory rate limit
DatabaseAtomic conditional decrements, durable counters, audit trailCheap; every check is a round tripHard stop lives here

AWS documents its own gateway throttling as token bucket applied on a best-effort basis, explicitly to be thought of as targets rather than guaranteed request ceilings 20. Take them at their word. A gateway is a fine place to stop a runaway loop from hammering your service, and a poor place to guarantee that customer 4471 does not exceed 50 dollars today.

The tool layer earns its keep for a different reason. Agents that receive an explicit budget signal behave differently: the Budget Tracker work from Google Cloud AI Research and Google DeepMind found that simply granting agents a larger tool-call budget fails to improve performance because standard agents lack budget awareness and hit a ceiling, while continuous budget signals produced more favorable scaling curves 17. Telling the model "you have 3.20 dollars of 10.00 left for this run" changes its planning. That does not replace the database check, it just makes the check fire less often.

Lightweight gateway implementations show the same shape. LiteLLM's iteration budgets apply a max iterations count and a per-session dollar cap, tracked by a trace ID, and return 429 when either is exceeded, with counters expiring after one hour by default 19. That handles a runaway loop inside one session. It does not handle two sessions for the same customer on the same day, which is why per-customer and per-day counters still belong in your own store.

The reserve and settle pattern

The core primitive is a reserve that cannot oversubscribe. You hold a budget row per (customer, day) and a separate hold per run, and you claim against both inside one transaction.

CREATE TABLE spend_budget (
  scope_kind   text    NOT NULL,   -- 'customer_day', 'run'
  scope_id     text    NOT NULL,
  limit_cents  bigint  NOT NULL,
  used_cents   bigint  NOT NULL DEFAULT 0,
  reserved_cents bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (scope_kind, scope_id)
);

CREATE TABLE spend_ledger (
  idempotency_key text PRIMARY KEY,
  scope_id        text   NOT NULL,
  amount_cents    bigint NOT NULL,
  state           text   NOT NULL,  -- 'reserved', 'settled', 'released'
  created_at      timestamptz NOT NULL DEFAULT now()
);

The claim is a single statement per scope, and it either matches one row or none:

UPDATE spend_budget
SET reserved_cents = reserved_cents + $amount
WHERE scope_kind = $kind
  AND scope_id   = $id
  AND used_cents + reserved_cents + $amount <= limit_cents
RETURNING limit_cents, used_cents, reserved_cents;

Zero rows returned means the cap would be broken. Run both the customer-day claim and the run claim in the same transaction. If the second returns zero rows, roll back so the first claim never happened. This is the reserve half.

Settlement follows the external call. On success, move the reservation into used; on failure, release it. Both are trivial updates:

-- settle
UPDATE spend_budget SET reserved_cents = reserved_cents - $amt,
                        used_cents     = used_cents + $amt
WHERE scope_kind = $kind AND scope_id = $id;

UPDATE spend_ledger SET state = 'settled' WHERE idempotency_key = $key;

Two rules make this safe under load. First, the ledger insert and the budget update go in the same transaction, so a crash cannot leave a reservation with no record or a record with no reservation. Second, the ledger insert uses the idempotency key as a primary key, so a duplicate request fails the insert before it touches any counter. Stripe's design document is explicit about this: the server correlates the key with the state of the request, and on a retry after a response failure it simply replies with the cached result 5. A practical guide to the same mechanism notes that stores should keep keys for a window long enough to cover typical retries, usually 24 hours to 7 days, and reject a key reused with different request parameters 6.

Worked example: one customer, one day, three overlapping runs

Assume a customer-day cap of 5,000 cents and a per-run cap of 2,000 cents. Three runs start within the same second.

StepScopeRequestReserved beforeUsed beforeOutcome
1run A2,00000Claim ok, limit is 2,000
2customer_day2,00000Claim ok, 2,000 of 5,000
3run B2,00000Claim ok
4customer_day2,0002,0000Claim ok, 4,000 of 5,000
5run C2,00000Claim ok
6customer_day2,0004,00000 rows, roll back
7run A settles1,850 actualUsed 1,850, reservation released
8run B settles2,000 actualUsed 3,850
9run C retries2,00003,8500 rows, capped

Run C fails with a budget error even though its own run counter is empty, because the shared day counter is nearly full. That is the correct outcome: the customer-level ceiling is the promise you made.

Note step 7. Reservations should be conservative, but settlement records the actual charge, which can be lower. That gap is the difference between a hold and a debit and it is why you keep them in separate columns.

Idempotency at the tool boundary

A cap is only as good as the identity of a spend attempt. Generate the key at the tool boundary, before the first call, and reuse it on every retry of the same logical action. Client-side guidance is consistent on this: create the key once, keep it, and resend the same key on every retry, and never build a key from a timestamp or a random number because two distinct payments could collide 2.

The backend's response table is small and worth memorizing:

Server finds the keyAction
Not presentReserve, execute, settle, store the result
Present and in progressReturn 409, "still working"
Present and finishedReturn the stored result, do not execute
Present but bound to a different payloadReturn 422, possible key misuse

The concurrency detail matters for production. Multiple requests with the same key can arrive at once, and the system must guarantee only one of them executes while the others wait or receive the cached response, which is typically achieved with database-level or distributed locks 6. In a single relational database, the primary key on the ledger is your lock, and you do not need a separate distributed lock at all. Check the ledger first, and if the key exists, return its stored state.

One more subtlety: the provider call needs its own key, derived from yours and saved before the call. If your process crashes after the provider charged but before you recorded the result, the retry reuses the provider key, and the provider returns the original charge instead of creating a new one 3. Your reservation stays held through the crash and settles on the retry.

Token buckets, day boundaries and clock skew

Rate limits and monetary caps are different instruments and it helps to be explicit about which you are tuning.

A token bucket refills at a steady rate and permits bursts, which is what you want for protecting a service from a hot loop. A day cap is a step function: no spending past the boundary until reset. Mixing the two produces confusing behavior, such as a customer who hits a "budget" error at 11pm and is fine again at 11:01pm because your bucket refilled.

Budget semantics should be pure accumulation with an explicit reset boundary. Compute the day key from the customer's billing timezone, not from the server's clock, and store it as a string such as 2026-03-14:acct4471. Store the boundary in the key itself, so a timezone change produces a new row instead of retroactively editing an old one. When you reschedule the boundary, never mutate past rows; start a fresh row and leave the audit trail alone.

For burst protection, add a separate token bucket per customer in front of the tool, with a low refill rate. AWS's own documentation describes the mechanics accurately: the token bucket permits a burst and then settles to the steady rate, and the throttle is a target rather than a guarantee 20. Use it to shed load, not to enforce money.

There is one hard case worth calling out. If your day cap is denominated in a currency and your customers spend in several, accumulate in a single base currency using a rate you snapshot at reservation time and store on the ledger row. Recomputing historic spend at today's rate makes yesterday's overrun disappear, which is a compliance problem in a regulated context.

Hard stops beat silent throttling

The most consequential design decision is what happens when the cap is reached. Silent throttling, where the tool quietly returns a degraded result or a smaller amount, is tempting because the agent keeps going, and it is wrong for money.

A hard stop returns an error the agent can reason about and the operator can audit. For an agent loop, that error should be structured and terminal for the run:

{
  "error": "budget_exceeded",
  "scope": "customer_day",
  "scope_id": "2026-03-14:acct4471",
  "limit_cents": 5000,
  "used_cents": 3850,
  "reserved_cents": 2000,
  "retry_after": "2026-03-15T00:00:00Z"
}

Two properties of that payload do real work. It tells the model why it cannot proceed, which converts a retry loop into a different plan, and it names the scope and the reset time, which lets the harness decide whether to escalate to a human. The distinction between human-in-the-loop, human-on-the-loop and human-in-command oversight is well established, with in-the-loop meaning a person can intervene before each consequential action takes effect, and the main failure mode being approval fatigue and rubber-stamping 10. A budget error is a natural escalation trigger, but only if it is loud.

Gate the riskiest writes the same way. Practitioner guidance from MLflow is blunt that read, write and destructive operations should carry different approval requirements, and that standing permissions to send email, move funds, or modify production without explicit human approval should be prohibited as a structural control rather than a UX preference 13. A cap is the automated version of that control; approval is the version for one-off exceptions above it.

Pair the error with observability you can actually query. Agent observability extends the traditional metrics, logs and traces with evaluations and governance, because agents are non-deterministic and their errors produce downstream effects that chat-only systems do not 14. Emit a span every time a reservation is made or refused, tagged with the scope ID, so a budget question becomes a filtered trace query instead of a reconciliation exercise.

Testing that the cap actually holds

Caps fail in boring ways: an off-by-one at the boundary, a settlement that runs twice, a rollback that leaves the reservation stranded. Test these explicitly.

TestSetupExpected
Exact fitCap 2,000, request 2,000Allowed, used equals limit
One cent overCap 2,000, request 2,001Rejected, counters unchanged
Concurrent identical keysTwo requests, same key, same payloadOne execution, one settlement
Concurrent different keysTwo requests, each 2,000, cap 3,000One allowed, one rejected
Crash after provider callKill process post-charge, pre-settleRetry reuses provider key, settles once
Failed runProvider returns declineReservation released, used unchanged
Day rolloverRequest at boundary in customer timezoneNew key, fresh limit, old row untouched

The concurrency tests are the ones that catch real money. Run them with real parallelism against a real database, not an in-memory stub, because the guarantee you are buying comes from the row-level lock. A test that passes with a single-threaded fake proves nothing about the property you care about.

Then rehearse the operational side. Ask what a support agent sees when a customer says the tool refused a payment, and make sure the answer requires one query, not three. The ledger holds the idempotency key, the scope, the amount, and the state, which is enough to explain any refusal.

Where this fits your stack

The pattern is portable. If you self-host a gateway, set channel-level budgets and iteration caps there for the LLM calls, and keep the money counters in your own database as described. If your agent runs through a single API, budget tracking per request is easier when every model call bills through one place, which is the pitch for Quanta Inference: text, image, video, audio and embedding models through one key, OpenAI-compatible and prepaid, so a spend record from one provider covers all of them.

For teams whose agents do real finance work rather than toy demos, the cost of getting this wrong is a customer with a double charge and an auditor with questions. If you want the counter design reviewed against your actual schema, or want the agent built with these controls from the start, describe it at Quanta and a specialist picks it up.

FAQ

FAQ

Where should the cap check live if I only have time for one?

Put it in the database, in the same transaction that records the spend. A conditional update that returns zero rows when the limit would be broken is atomic, survives retries and multiple instances, and leaves an audit trail. Everything else is an optimization.

What is the difference between a reserve and a settle?

A reserve holds budget for an in-flight call so two concurrent runs cannot both claim the same headroom. A settle converts the hold into an actual debit, typically at the final amount, which may be lower than the reservation. Keeping them in separate columns lets a crash leave a recoverable hold rather than an unexplained charge.

Do I still need an API gateway if the database enforces the cap?

Yes, for a different job. The gateway sheds bursts and enforces per-customer request rates cheaply, before requests reach your service. Treat those limits as targets rather than guarantees, as AWS does with its own throttling, and let the database own the money.

Should a capped agent get an error or a reduced result?

An error, always. Silent throttling teaches the agent that partial work is acceptable and hides the refusal from operators. A structured budget error that names the scope, the limit and the reset time lets the agent change plan and gives the operator something to escalate on.

How long should idempotency keys be retained?

Long enough to cover every realistic retry: commonly 24 hours to 7 days. Expiring too early re-exposes the double-charge window; keeping them forever costs storage and slows lookups. Tie the retention to your reservation expiry so a key never outlives the hold it protects.

Sources

Sources

  1. Idempotency Keys in Payment APIs: The Small Header ...
  2. Designing robust and predictable APIs with idempotency - Stripe
  3. What happens when an AI agent is allowed write access instead of ...
  4. Throttle requests to your REST APIs for better throughput in ...
  5. Budget-Aware Tool-Use Enables Effective Agent Scaling
  6. Agent Iteration Budgets
  7. Implementing Idempotency Keys in REST APIs - Zuplo
  8. Idempotency Keys - The Double-Charge Trap
  9. Idempotent requests | Stripe API Reference
  10. What Is Human-in-the-Loop AI in Finance? | Keboola
  11. AI Agent Tool Use Best Practices for Practitioners - MLflow
  12. Top 5 agent observability best practices for reliable AI
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles