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

Source: https://docs.getrunstate.com/guides/budgets/

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.

## Create a budget

**TypeScript**

```ts
// 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' });
```

**Python**

```python
# 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

**TypeScript**

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

**Python**

```python
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.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.

## Check the balance

**TypeScript**

```ts
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)}`,
);
```

**Python**

```python
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

- **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](https://docs.getrunstate.com/api/budgets/) (`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](https://docs.getrunstate.com/configuration/#api-keys-and-permissions).

## Related

- [Quotas and concurrency pools](https://docs.getrunstate.com/guides/share-capacity/): quotas and pools for rate limits and slots.
- [Events and monitoring](https://docs.getrunstate.com/guides/observability/): `budget.reserved`, `budget.settled` and `budget.voided` events.
- API reference: [Budgets](https://docs.getrunstate.com/api/budgets/).
