Spend budgets
Give the swarm one spend ceiling. Agents reserve before expensive work, settle the actual cost exactly once, and release what they didn't use.
A hundred agents shouldn’t get a hundred independent chances to overspend. A runstate budget is one spend ceiling shared by every agent that uses it:
- Reserve an amount before the expensive call. If the budget can’t cover it, the reservation fails and the work doesn’t start.
- Settle what the call actually cost, keyed by a usage id so a duplicate report never charges twice.
- Void whatever is left of the reservation.
Amounts are decimal strings in major units ("0.40" is 40 cents), converted to integers on the server. There is no floating-point arithmetic anywhere in the path.
Create a budget
Section titled “Create a budget”// Once, at setup. `scale` is the number of decimal places (2 for cents).await rs.budgets.ensure('research-usd', { currency: 'USD', scale: 2, limit: '200.00' });# Once, at setup. `scale` is the number of decimal places (2 for cents).await rs.budgets.ensure("research-usd", currency="USD", scale=2, limit="200.00")currency is a three-letter code and scale is 0 to 9. Like other ensure calls, it creates the budget or confirms that an existing one has the same limit and scale, and throws a CONFLICT error if it doesn’t. Budgets belong to the space; agents use them by name inside a run.
Reserve, settle, void
Section titled “Reserve, settle, void”import { ConflictError, type ScopeHandle } from 'runstate-sdk';
async function summarizeWithBudget(run: ScopeHandle): Promise<string | null> { let reservation; try { reservation = await run.budget('research-usd').reserve({ amount: '0.40' }); } catch (err) { if (err instanceof ConflictError && err.code === 'INSUFFICIENT_BUDGET') { return null; // budget exhausted: don't start the call } throw err; }
const response = await callExpensiveModel(); // Record the real cost. Settling the same usageKey again is a no-op. const settled = await reservation.settle({ amount: response.usd, usageKey: response.requestId }); // Release whatever is still reserved. if (settled.state !== 'SETTLED') { await reservation.void({ usageKey: `void:${response.requestId}` }); } return response.text;}from runstate import ConflictError, ScopeHandle
async def summarize_with_budget(run: ScopeHandle) -> str | None: try: reservation = await run.budget("research-usd").reserve(amount="0.40") except ConflictError as err: if err.code == "INSUFFICIENT_BUDGET": return None # budget exhausted: don't start the call raise
response = await call_expensive_model() # Record the real cost. Settling the same usage_key again is a no-op. settled = await reservation.settle(amount=response.usd, usage_key=response.request_id) # Release whatever is still reserved. if settled["state"] != "SETTLED": await reservation.void(usage_key=f"void:{response.request_id}") return response.textHow the pieces behave:
- Reservations never oversubscribe. Reservations are checked against what’s available (limit minus reserved minus settled) atomically on the server, so concurrent agents can’t jointly reserve more than the limit. The one that doesn’t fit gets
INSUFFICIENT_BUDGET(ConflictError). - Settle is idempotent per usage key. Settling again with the same
usageKeyand amount returnsreplay: trueand charges nothing. The same key with a different amount is rejected withIDEMPOTENCY_CONFLICT. Use the provider’s request or invoice id as the key. - You can settle in parts. Each settle (with its own key) moves that amount from reserved to settled. When nothing is left reserved, the reservation is
SETTLED. - You can’t settle more than you reserved. A settle larger than the remaining reservation fails with
VALIDATION_FAILED. If a call costs more than expected, reserve the difference separately before recording it. - Void releases the remainder and marks the reservation
VOID. It is idempotent perusageKeyas well. Voiding a reservation that is already fully settled or has nothing left returnsCONFLICT, so check thestatethatsettle()returns first. - Nothing is refunded automatically. Pass
settleBy(an ISO 8601 timestamp) when you reserve, and a reservation still open at that time is markedSTALE, which makes it easy to find instatus(). Its amount stays reserved until you settle or void it.
Check the balance
Section titled “Check the balance”import { minorToDecimal } from 'runstate-sdk';
const budget = await run.budget('research-usd').status();console.log( `limit ${minorToDecimal(budget.limitMinor, budget.scale)}`, `reserved ${minorToDecimal(budget.reservedMinor, budget.scale)}`, `settled ${minorToDecimal(budget.settledMinor, budget.scale)}`, `available ${minorToDecimal(budget.availableMinor, budget.scale)}`,);from runstate import minor_to_decimal
budget = await run.budget("research-usd").status()scale = budget["scale"]print( "limit", minor_to_decimal(budget["limitMinor"], scale), "reserved", minor_to_decimal(budget["reservedMinor"], scale), "settled", minor_to_decimal(budget["settledMinor"], scale), "available", minor_to_decimal(budget["availableMinor"], scale),)status() returns balances in minor units (integer strings) and the list of reservations with their states. decimalToMinor / decimal_to_minor and minorToDecimal / minor_to_decimal convert with integer arithmetic only. At any moment, reserved + settled + available equals the limit.
Things to know
Section titled “Things to know”- Keep the reservation object. The SDKs have no method to rebuild a reservation handle from its id. If a process crashes between reserve and settle, the amount stays reserved; reconcile it with the HTTP API (
POST /budgets/{id}/settleor/voidwith thereservationId, which appears instatus()). - The ceiling covers calls that go through runstate. An agent that calls the provider without reserving isn’t stopped by the budget.
- Permissions. Reserving, settling and voiding need
coordination_write; creating a budget withensure()needsresource_config. See Configuration & auth.
Related
Section titled “Related”- Quotas and concurrency pools: quotas and pools for rate limits and slots.
- Events and monitoring:
budget.reserved,budget.settledandbudget.voidedevents. - API reference: Budgets.