Skip to content

Budgets

Budget envelopes let you cap how much a Routeweiler client can spend, per agent or per session. Enforcement runs locally via SQLite — no network call required.

Why envelopes?

Without a budget envelope, Routeweiler pays every 402 it encounters up to the policy's per-call cap (if any). An envelope adds a cumulative cap: the client stops paying when the envelope's total spend reaches cap_minor_units.

This is useful for:

  • Multi-tenant agents — give each agent its own envelope so one rogue agent can't drain the wallet.
  • Session budgets — create a fresh envelope per user session and let it expire.
  • Audit trails — every draw is recorded as a signed DrawReceipt in SQLite.

Creating an envelope

Pass a BudgetEnvelope directly to the client. The envelope is created idempotently when the client enters its async with block.

from routeweiler import Routeweiler, Funding, BudgetEnvelope
from routeweiler.trace.sink_sqlite import TraceSink

async with Routeweiler(
    funding=[Funding.base_usdc(wallet=signer)],
    trace_sink=TraceSink.sqlite("rw.db"),
    budget_envelope=BudgetEnvelope(
        id="session-abc",
        cap_minor_units=500,        # $5.00 in cents
        cap_currency="usd",
        allowed_rails=["x402", "l402"],
        ttl_seconds=3_600,          # 1-hour TTL
    ),
) as client:
    response = await client.get("https://api.example.com/data")

If an envelope with id "session-abc" already exists in rw.db, the spec is silently ignored and the existing envelope is reused.

Option B — pre-created envelope

Create the envelope separately (e.g. in a setup step), then reference it by id.

from routeweiler.budgets.local import BudgetStore
from routeweiler.budgets.keystore import EnvelopeKeystore
from routeweiler.trace.sink_sqlite import TraceSink

# One-time setup
store = BudgetStore("rw.db", EnvelopeKeystore())
await store.create_envelope(
    "agent-007",
    cap_minor_units=10_000,    # $100.00
    cap_currency="usd",
    allowed_rails=["x402"],
    ttl_seconds=86_400,        # 24 hours
)
await store.aclose()

# Later, in the client
async with Routeweiler(
    funding=[Funding.base_usdc(wallet=signer)],
    trace_sink=TraceSink.sqlite("rw.db"),
    budget_envelope="agent-007",
) as client:
    ...

Option C — explicit namespace (for dynamic envelope management)

async with Routeweiler(
    funding=[...],
    trace_sink=TraceSink.sqlite("rw.db"),
) as client:
    await client.envelopes.create(
        "run-42",
        cap_minor_units=200,
        cap_currency="usd",
        allowed_rails=["x402", "mpp-tempo"],
        ttl_seconds=600,
    )
    # Then use envelope by id:
    # (this requires a second client instance referencing "run-42")

Envelope lifecycle

create_envelope(id, cap, ...)   → envelope is ACTIVE
draw(envelope_id, ...)          → reserves amount, returns DrawReceipt
    ├── confirm(receipt_id)     → reserved → SETTLED
    └── rollback(receipt_id)    → reserved → ROLLED_BACK (on payment failure)

Routeweiler manages draw/confirm/rollback automatically during the auth flow. You only call create_envelope (or pass BudgetEnvelope).

BudgetEnvelope fields

Field Type Description
id str Unique envelope identifier.
cap_minor_units int Spending cap in the currency's minor units (cents for USD/EUR/GBP, whole yen for JPY).
cap_currency "usd" | "eur" | "gbp" | "jpy" Reference currency.
allowed_rails list[Rail] Only these rails may draw from this envelope.
ttl_seconds int Envelope lifetime in seconds from creation.
allowed_origins_glob list[str] | None URL glob patterns allowed to draw. Defaults to ["*"].
owner_agent_id str | None Optional agent identifier for this envelope.

DrawReceipt — signed draw authorization

Every draw produces a DrawReceipt: an Ed25519-signed token recording the exact amount reserved, the rail used, and an idempotency key. Routeweiler verifies the signature before confirming or rolling back a draw.

The receipt is stored in the draws table in the same SQLite database as the traces.

import sqlite3, json

conn = sqlite3.connect("rw.db")
draws = conn.execute("SELECT * FROM draws WHERE envelope_id = ?", ("session-abc",)).fetchall()
for draw in draws:
    print(draw)  # (receipt_id, envelope_id, state, amount_reserved_minor_units, ...)
conn.close()

Budget errors

Error Cause
BudgetExceededError The requested amount would exceed the envelope cap.
EnvelopeNotFoundError The referenced envelope_id does not exist in the DB.
EnvelopeFrozenError The envelope has been frozen (e.g. by the reaper).
EnvelopeExpiredError The envelope's TTL has elapsed.

All are subclasses of BudgetError which is itself a subclass of PaymentError.

from routeweiler import BudgetExceededError

try:
    response = await client.get(url)
except BudgetExceededError as exc:
    print(f"Budget cap hit: {exc}")

Notes

  • Budget enforcement requires a trace_sink. Without one, no enforcement runs regardless of whether a budget_envelope is passed.
  • FMV conversion (USD equivalent of sats, USDC, PathUSD) is done via CoinGecko and ECB rates, cached locally. The fmv_quality field in the trace indicates whether the conversion was based on a stablecoin peg, a live CoinGecko price, or was unavailable.