Skip to content
HomeConsoleGet started

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:

  1. Reserve an amount before the expensive call. If the budget can’t cover it, the reservation fails and the work doesn’t start.
  2. Settle what the call actually cost, keyed by a usage id so a duplicate report never charges twice.
  3. 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.

// 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.

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;
}

How 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 usageKey and amount returns replay: true and charges nothing. The same key with a different amount is rejected with IDEMPOTENCY_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 per usageKey as well. Voiding a reservation that is already fully settled or has nothing left returns CONFLICT, so check the state that settle() returns first.
  • Nothing is refunded automatically. Pass settleBy (an ISO 8601 timestamp) when you reserve, and a reservation still open at that time is marked STALE, which makes it easy to find in status(). Its amount stays reserved until you settle or void it.
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)}`,
);

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.

  • 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}/settle or /void with the reservationId, which appears in status()).
  • 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 with ensure() needs resource_config. See Configuration & auth.