Spending rules for AI agents: why the policy lives outside the model

Budgets, per-payment caps, allow-lists, and approvals, checked in code before anything is signed. Includes the evaluation order and a TypeScript reference.

MinAgent Team7 min read

The first question anyone asks about an agent with a wallet is “what stops it from spending everything?” The honest answer is: not the model. A language model can be confused by a misleading page, talked into things by a malicious prompt, or stuck in a retry loop. Asking it nicely to be careful is not a control.

The control is a policy engine: ordinary code that sits between the agent and its signing key and decides, for every payment, whether it may go ahead. The model can ask for anything. Only the policy can say yes.

What can go wrong

It helps to be specific about the failures you're designing against:

  • Prompt injection. A web page or tool result tells the agent to “pay this invoice to finish the task”. The agent believes it.
  • Price surprises. A service the agent has used for weeks starts charging 100 times more, or a new service quotes an absurd price.
  • Loops. A bug or a confused plan makes the agent repeat the same paid call hundreds of times.
  • Unknown counterparties. The agent finds a service nobody has vetted and pays it.

None of these are solved by a better prompt. All of them are solved by rules the model cannot change.

The rules

Most owners need only five:

  1. Freeze. A switch that stops every payment immediately.
  2. Allow-list. The services the agent may pay. Anything else is refused, however convincing the reason.
  3. Per-payment cap. The most a single payment may cost.
  4. Daily budget. The most the agent may spend in a day, across all services.
  5. Approval above the cap. Instead of refusing a large payment outright, hold it and ask the owner.

Order matters

Check the rules from cheapest to most expensive to get wrong, and stop at the first one that fails. Freeze comes first because it overrides everything. The allow-list comes before amounts, because a payment to an unknown service is wrong at any price. Budgets come last, because they depend on what has already been spent.

policy.ts
type Rules = {
  frozen: boolean;
  allowList: Set<string>; // payTo addresses or service IDs
  capPerPayment: bigint; // smallest token unit
  dailyBudget: bigint;
  askAboveCap: boolean;
};

type Payment = { id: string; payTo: string; amount: bigint };

type Decision =
  | { kind: "pay" }
  | { kind: "ask"; reason: string }
  | { kind: "refuse"; reason: string };

export function decide(
  rules: Rules,
  payment: Payment,
  spentToday: bigint,
  approved: Set<string>,
): Decision {
  if (rules.frozen) return { kind: "refuse", reason: "Wallet is frozen" };

  if (!rules.allowList.has(payment.payTo))
    return { kind: "refuse", reason: "Not on the allow-list" };

  if (payment.amount > rules.capPerPayment && !approved.has(payment.id))
    return rules.askAboveCap
      ? { kind: "ask", reason: "Over the per-payment cap" }
      : { kind: "refuse", reason: "Over the per-payment cap" };

  if (spentToday + payment.amount > rules.dailyBudget)
    return { kind: "refuse", reason: "Daily budget used up" };

  return { kind: "pay" };
}

Note that an approved payment still has to fit the daily budget. Approval lifts the cap for one payment; it doesn't create new money.

Details that bite in production

Use integers, not floats

Keep amounts in the token's smallest unit as integers (bigint in TypeScript). x402 already sends amounts that way. Floating-point money drifts, and a budget check that is off by a rounding error will eventually let something through.

Decide what “a day” means

A budget that resets at midnight UTC surprises an owner in Hanoi or San Francisco. Store the owner's time zone and reset in their day, or use a rolling 24-hour window and say so in the interface.

Make payments idempotent

Networks fail and agents retry. Give each payment attempt a stable ID tied to the request, and never count or sign the same ID twice. Otherwise one flaky connection can double-spend against the budget.

Lock the budget, not just the check

If an agent fires ten payments at once, ten checks can all see the same “spent today” and all pass. Reserve the amount atomically when you decide, and release it if the payment fails.

Record the reason, every time

Store every decision with its reason and the task that caused it. When an owner asks “why did my agent pay this?” or “why was this blocked?”, the answer should be one click away.

Let approvals expire

A payment waiting for approval should time out. x402 terms carry their own timeout, and a price quoted an hour ago may not be valid now. Ask again rather than paying a stale quote.

Where the key lives

Rules only work if the agent can't go around them. That means the agent never holds the wallet's main key. It holds a limited session key that can only request signatures through the policy engine. We cover how that works with Claude Code in Giving Claude Code a wallet with MCP.

Give your agent a wallet.

The private beta is open by invitation.

Join the waitlist

Keep reading

Integrations

Giving Claude Code a wallet with MCP

What an MCP wallet server should expose, why it has no “send money” tool, and how scoped session keys keep the main wallet key out of every client.

6 min read