How to give an AI agent a spending limit it cannot exceed
The short answer: the limit has to be enforced by whatever holds the money-moving credential, in a single atomic operation, before the action goes out. Not in the prompt, not in the agent's code, and not by a check-then-act sequence — because check-then-act loses to concurrency, and an agent is the most concurrent thing you have ever run.
The three ways this is usually done wrong
In the system prompt. "You may spend up to $10,000." This is a request, not a limit. It holds until the model has an off day, gets prompt-injected by a malicious invoice, or reasons its way to an exception — and then it holds no longer.
In the agent's own code. if total + amount > budget: refuse. Better, but the agent still holds a
credential that can move more money than the budget allows. Anything that gets code execution in your
agent — including the agent itself, creatively — spends whatever it likes. A limit enforced by the
thing being limited is not a limit.
Check, then act. The classic:
spent = db.query("SELECT SUM(amount) FROM payments WHERE mandate = ?") # $9,900 of $10,000
if spent + amount <= budget: # ✓ passes
stripe.transfer(amount) # ...and so does the other one
db.insert(payment)
Run two $500 payments concurrently and both read $9,900, both pass the check, and you have
spent $10,900 against a $10,000 budget. This is not a rare race — it is the normal behaviour of
an agent fleet, which is parallel by construction and retries on timeout.
What actually holds
The guard has to be inside the write, so the database's own concurrency control does the work:
UPDATE mandates
SET spent_minor = spent_minor + :amount
WHERE mandate_id = :mandate
AND status = 'active'
AND spent_minor + :amount <= budget_minor -- ← the guard IS the query
RETURNING spent_minor;
Zero rows returned means the spend was refused, and it was refused atomically. Two concurrent
$500 authorizations against a $9,900-of-$10,000 mandate cannot both succeed, because the second one's
WHERE clause is evaluated against the first one's committed write. There is no window between the
check and the act, because they are the same statement.
This is what authoxi does on every authorization. The 51st dollar of a $50 mandate never passes, and concurrent authorizes cannot jointly overshoot. It is one round trip, it needs no distributed lock, and it is the entire mechanism.
verdict = cp.authorize(
agent_id=agent["agent_id"],
mandate_id=mandate["mandate_id"],
action="payment",
amount="500.00",
)
verdict["decision"] # "allow" | "deny" | "escalate"
verdict["reason_code"] # "budget_exceeded" on a deny — a structured reason, not free text
Two limits, not one
A single number is not enough, because "how much can it spend in total" and "how much can it spend without asking" are different questions:
mandate = cp.issue_mandate(
agent_id=agent["agent_id"],
budget="10000.00", # the hard ceiling. Nothing gets past this. Ever.
escalate_over="1000.00", # above this, a human signs. Below it, the agent is autonomous.
actions=["payment"], # and it can do nothing except payments
)
Without escalate_over, you get a binary: either the agent is trusted with $10,000 or it is useless.
With it, the agent handles the long tail of small payments on its own and
a human signs the ones that could hurt you.
Money is not the only budget
The same ledger governs token spend, with one important difference in how it's enforced. Rather than proxying your inference calls (we're not in that path, deliberately — it's a commodity and a key-custody liability), the budget is enforced at credential issuance:
Over budget → the agent's short-lived passport is not refreshed → within one TTL the agent can reach neither a model nor a tool.
The trade is honest and you should know it: enforcement lags by up to one passport TTL (≤ 5 minutes). If that ever bites, the fix is a shorter TTL, not an inference proxy.
Decimal strings, never floats
Every amount in the API and in the signed record is a decimal string
("9000.00"), and floats are rejected outright rather than coerced.
Two reasons, and the second is the one people miss. First, 0.1 + 0.2 != 0.3 and you are counting
money. Second, a float does not serialize portably — so it cannot be signed reproducibly, and a
record you cannot reproduce byte-for-byte is a record nobody else can verify. The signature discipline
reaches all the way down into the type system.
What this does not protect you from
- An agent that spends the budget on the wrong thing. A $500 payment to a fraudulent vendor is within budget. That is what per-action permissions and human escalation are for. A budget bounds the blast radius; it does not make the decisions good.
- A credential that bypasses the gate entirely. If the agent holds your Stripe key directly, none of this applies — it just spends. The limit only exists if the gate, and not the agent, holds the credential.