A $47K Agent Loop: Add a Hard Spend-Cap in 40 Lines


A pre-execution spend-cap stops a runaway AI agent before the next action runs, not after. SpendGuard enforces four budget ceilings — daily USD, per-action USD, step count, and wall-clock time — and raises an exception when an action would breach any of them. It is about 40 lines, keyless, read-only, and moves zero funds.

Last November, a market-research pipeline of four LangChain agents ran for 11 straight days before anyone noticed. Two of them — an Analyzer and a Verifier — got stuck asking each other to “please clarify section 3.2” with effectively the same payload, every cycle. The invoice came to $47,000. Week 1 was $127. Week 4 was $18,400. The dashboard was working the whole time. It just had nothing to say except yes, you are still spending money.

That story is about token bills. But if you put an agent behind a wallet, the currency of a runaway loop changes from API tokens to gas and trades — and the failure mode is identical. Here’s a hard pre-execution brake you can drop into a crypto agent today. It’s about 40 lines, runs with no API key, and moves zero funds.

TL;DR

  • Tracking is not control. A dashboard tells you what you already spent; it can’t stop the next call.
  • The fix is a pre-execution gate: check the budget before the action runs, and refuse to run it if it’s over.
  • SpendGuard enforces 4 ceilings before every action: daily USD, per-action USD, step count (anti-loop), and wall-clock time.
  • It prices gas live from CoinGecko + a public Ethereum RPC — keyless, read-only, no signing.
  • This is a seatbelt, not an audit. Limits below.

Why your dashboard never stops the bleed

Every “agent cost” tool I’ve seen does the same thing: it watches spending and fires an alert past a threshold. Grafana panel, Slack ping, a red number. Useful. But look at where it sits in the timeline.

The alert fires after the call returns. By then the money is gone. In the $47K loop, the post-mortem named the missing piece directly: there was no enforcement mechanism that would have terminated the session before the next API call. An alert at 3am UTC is a smoke detector that emails you a photo of your house.

A crypto agent makes this worse, not better. A token loop burns dollars linearly. A swap loop burns gas on every attempt, can get sandwiched, can re-approve allowances, can chase a price that keeps moving. The loop doesn’t need to be malicious. A retry-on-failure that always fails is enough.

So the question isn’t “how do I see the spend faster.” It’s “how do I make the over-budget action physically not happen.”

The shape of the fix: gate, don’t log

The whole idea fits in one sentence: before an action runs, ask “would this push me over a limit?” — and if yes, raise an exception so the action never executes.

That’s a check() that runs first and a commit() that runs only after a real action goes through. Four limits, each catching a different way agents bleed money:

  • Daily USD cap — total spend per run. The hard ceiling on the whole session.
  • Per-action USD cap — one fat-fingered swap can’t blow the budget alone.
  • Step cap — the anti-loop. The Analyzer/Verifier ping-pong dies at step N no matter how cheap each step is.
  • Wall-clock cap — a kill-switch for “it’s been running for 11 days.” Time is the limit that catches the failures the dollar caps miss.

You need all four because they fail independently. Cheap-but-infinite is caught by the step cap. Slow-but-cheap is caught by wall-clock. One expensive mistake is caught by per-action. And the sum is caught by the daily cap.

SpendGuard — the full thing

Stdlib plus requests. No web3, so it installs and runs everywhere. All network calls are read-only: CoinGecko’s keyless /simple/price for ETH→USD, and a public Ethereum JSON-RPC for eth_gasPrice and eth_estimateGas. No private key is ever loaded; nothing is signed; nothing is sent.

#!/usr/bin/env python3
"""SpendGuard - a hard, pre-execution spend-cap for on-chain agents.
Stdlib + requests only. No keys, no signing, no funds moved.
Run: pip install requests && python spendguard.py"""
from __future__ import annotations
import functools, time
from dataclasses import dataclass, field
import requests

COINGECKO_PRICE = "https://api.coingecko.com/api/v3/simple/price"
PUBLIC_RPCS = ("https://ethereum-rpc.publicnode.com",
               "https://cloudflare-eth.com",
               "https://eth.llamarpc.com")  # tried in order; first to answer wins
HTTP_TIMEOUT = 12

class BudgetExceeded(Exception):
    """Raised BEFORE an action runs when it would breach a limit."""

def eth_price_usd() -> float:
    r = requests.get(COINGECKO_PRICE,
                     params={"ids": "ethereum", "vs_currencies": "usd"},
                     timeout=HTTP_TIMEOUT)
    r.raise_for_status()
    return float(r.json()["ethereum"]["usd"])

def _rpc(method: str, params: list) -> str:
    payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
    last_err = None
    for url in PUBLIC_RPCS:
        try:
            r = requests.post(url, json=payload, timeout=HTTP_TIMEOUT)
            r.raise_for_status()
            data = r.json()
            if "result" in data:
                return data["result"]
            last_err = data.get("error")
        except requests.RequestException as e:
            last_err = e
    raise RuntimeError(f"all public RPCs failed: {last_err}")

def gas_price_gwei() -> float:
    return int(_rpc("eth_gasPrice", []), 16) / 1e9

def estimate_gas_units(tx: dict) -> int:
    return int(_rpc("eth_estimateGas", [tx]), 16)

def action_cost_usd(tx: dict, eth_usd: float, gwei: float) -> float:
    """USD gas cost of one action = gas_units * gas_price * ETH/USD."""
    gas_units = estimate_gas_units(tx)
    eth_fee = gas_units * (gwei * 1e-9)
    return eth_fee * eth_usd

@dataclass
class BudgetBrake:
    daily_usd_cap: float
    per_action_usd_cap: float
    max_steps: int
    wall_clock_seconds: float
    spent_usd: float = 0.0
    steps: int = 0
    _start: float = field(default_factory=time.monotonic)

    def check(self, next_action_usd: float) -> None:
        """Gate BEFORE the action. Raises BudgetExceeded; nothing has run yet."""
        elapsed = time.monotonic() - self._start
        if elapsed > self.wall_clock_seconds:
            raise BudgetExceeded(f"wall-clock: {elapsed:.1f}s > {self.wall_clock_seconds}s cap")
        if self.steps >= self.max_steps:
            raise BudgetExceeded(f"step cap: {self.steps} steps >= {self.max_steps} (loop guard)")
        if next_action_usd > self.per_action_usd_cap:
            raise BudgetExceeded(f"per-action: ${next_action_usd:.4f} > ${self.per_action_usd_cap:.4f} cap")
        if self.spent_usd + next_action_usd > self.daily_usd_cap:
            raise BudgetExceeded(f"daily cap: ${self.spent_usd:.4f} + ${next_action_usd:.4f} > ${self.daily_usd_cap:.4f}")

    def commit(self, action_usd: float) -> None:
        self.spent_usd += action_usd
        self.steps += 1

    def guard(self, cost_fn):
        """Decorator: cost_fn(*args, **kwargs) -> est USD; gate then run."""
        def deco(fn):
            @functools.wraps(fn)
            def wrapper(*args, **kwargs):
                est = cost_fn(*args, **kwargs)
                self.check(est)            # blocks here if over budget
                result = fn(*args, **kwargs)
                self.commit(est)
                return result
            return wrapper
        return deco

if __name__ == "__main__":
    print("SpendGuard demo - read-only, no keys, no funds moved\n")
    eth_usd = eth_price_usd()
    gwei = gas_price_gwei()
    print(f"ETH/USD (CoinGecko):     ${eth_usd:,.2f}")
    print(f"gas price (public RPC):  {gwei:.4f} gwei")

    sample_tx = {"to": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", "value": "0x0"}
    per_swap_usd = action_cost_usd(sample_tx, eth_usd, gwei)
    print(f"est. cost per swap:      ${per_swap_usd:.6f}\n")

    brake = BudgetBrake(
        daily_usd_cap=per_swap_usd * 3.5,
        per_action_usd_cap=per_swap_usd * 5,
        max_steps=8,
        wall_clock_seconds=30,
    )

    for i in range(100):
        try:
            brake.check(per_swap_usd)
        except BudgetExceeded as e:
            print(f"  step {i:>2}: BLOCKED -> {e}")
            break
        brake.commit(per_swap_usd)
        print(f"  step {i:>2}: swap OK   spent=${brake.spent_usd:.6f}")

    print(f"\nStopped after {brake.steps} swaps, ${brake.spent_usd:.6f} spent.")
    print("The loop wanted 100. The brake allowed what the budget allowed.")

How you’d actually wire it in

Two ways, pick your taste.

Context-style, explicit. Call check() right before you build and sign a transaction, commit() right after it’s mined. The check() is your point of no return — if it raises, you simply never reach the signing code.

Decorator-style. Wrap your do_swap() with @brake.guard(cost_fn), where cost_fn returns the estimated USD for that specific call. Now every wrapped action is gated automatically; you can’t forget to check.

The cost estimate uses the same three numbers a block explorer shows you: gas units (eth_estimateGas), gas price (eth_gasPrice), and the ETH price (CoinGecko). Multiply, and you have the dollar cost of an action before you commit to it. That’s the whole trick — pricing the action in the unit your budget is written in.

Run it

pip install requests
python spendguard.py

No account, no key, no testnet faucet. The two endpoints it hits are public: CoinGecko’s keyless /simple/price returned ETH in USD, and ethereum-rpc.publicnode.com answered eth_gasPrice and an eth_estimateGas for a plain ETH transfer (the canonical ~21k-gas case that won’t revert). If publicnode is rate-limiting you, the PUBLIC_RPCS tuple falls through to the next host.

Output

A real run (mainnet gas was sub-1 gwei that minute, so the dollar figures are tiny — that’s the point: the behavior, not the numbers, is what’s deterministic):

SpendGuard demo - read-only, no keys, no funds moved

ETH/USD (CoinGecko):     $1,669.72
gas price (public RPC):  0.1319 gwei
est. cost per swap:      $0.004675

  step  0: swap OK   spent=$0.004675
  step  1: swap OK   spent=$0.009351
  step  2: swap OK   spent=$0.014026
  step  3: BLOCKED -> daily cap: $0.0140 + $0.0047 > $0.0164

Stopped after 3 swaps, $0.014026 spent.
The loop wanted 100. The brake allowed what the budget allowed.

The exact dollar figures depend on live gas and ETH price the moment you run it. What’s not variable: the loop wants 100 swaps, and the brake stops it the step the budget runs out. That’s the part that has to be deterministic, and it is.

What this is not

I want to be honest about the edges, because a false sense of safety is worse than no brake at all.

  • It’s not an audit, and it’s not MPC. SpendGuard caps how much your agent can spend. It does nothing about whether the contract you’re calling is malicious, key custody, or signing policy. For those you want a real transaction-policy layer or MPC custody — Coinbase, for instance, ships agentic-wallet guardrails for exactly this reason. SpendGuard is the cheap layer you add first, not the only one. For the single bad transaction — a hallucinated address or a lied-to price — pair it with a pre-send transaction canary.
  • eth_estimateGas is an estimate. It can under-shoot for state-dependent calls, and a swap’s real cost includes slippage and MEV that gas estimation doesn’t see. Treat the per-action cost as a floor, and set caps with headroom.
  • The wall-clock cap is per-process. If your agent restarts, the clock resets. For a true daily cap across restarts you need to persist spent_usd and a date stamp to disk or a DB — left out here to keep it 40 lines, but it’s the first thing I’d add for production.
  • Public RPCs are best-effort. They rate-limit and occasionally 526. Fine for the brake’s read-only estimate; for anything load-bearing, use your own node or a keyed provider.

None of that changes the core claim: a pre-execution gate is the difference between a $127 bug and a $47,000 one.

The one question I’m still chewing on

The hardest cap to get right is the daily one across restarts. A naive file write works until two agent processes race on it. I’ve been leaning toward a single advisory lock + an append-only spend log, but I haven’t stress-tested it under concurrent agents yet. If you’ve solved cross-process budget enforcement for a fleet of agents, I’d genuinely like to hear how — drop it in the comments.

Part two of this series is live: a pre-send transaction canary for on-chain agents that catches hallucinated addresses and bad prices before the agent hits send.


Written with AI assistance and reviewed/edited by a human. The code in this post was run against the live CoinGecko and public Ethereum RPC endpoints before publishing; the dollar figures in the output are from a real run (2026-06-08).