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
DrawReceiptin SQLite.
Creating an envelope¶
Option A — declarative spec (recommended)¶
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 abudget_envelopeis passed. - FMV conversion (USD equivalent of sats, USDC, PathUSD) is done via CoinGecko
and ECB rates, cached locally. The
fmv_qualityfield in the trace indicates whether the conversion was based on a stablecoin peg, a live CoinGecko price, or was unavailable.