Skip to content

Spend Budgets

A spend budget is a US-dollar cap on what a user’s agents may spend over a UTC day or a UTC month. At a warn percentage (80% by default) the user is notified once; at 100% their agents are paused, and every new agent run is refused until the period rolls over or an admin raises the limit.

Budgets are off until an admin sets one. A user with no budget is not capped by cost. Enforcement lives in src/security/spend-budgets.ts; the table is spend_budgets (migration 0114_spend_budgets.sql).

Every budget belongs to one user and is identified by (user, scope, period). Saving a budget with the same user, scope and period replaces the existing one.

ScopescopeRefCounts
usernoneevery cost_log row of the user (“All agents”)
rolerole name, trimmedthe user’s rows whose agent has that role
workspaceworkspace id (UUID, lowercased)the user’s rows attributed to that workspace
group_channelthe group channel’s enrolment idevery member’s rows in the channel’s sessions

A role or workspace budget narrows one user’s spend; it is not shared across the users of that role or workspace. A group channel budget is the exception: it caps what a group channel costs, whoever asks.

  • Set on Admin → Group channels, per channel: period, limit and warn %. One per channel and period.
  • Filed under the channel’s owner, who gets the warning and pause notifications. It moves to the new owner when a member takes the channel over, and is deleted with the enrolment.
  • Counted from cost_log rows whose session is one of the channel’s thread sessions (sessions.group_channel_id), for every member. A thread session deleted by the retention sweep (idle past sessions.retentionDays, 14 days by default) no longer counts, so a monthly channel budget can under-count threads that went quiet earlier in the month. A thread with a taken task still open is never swept.
  • Checked for every agent run in one of those sessions, next to the member’s own budgets (checkSpend gets the run’s sessionId). Before it starts a turn the channel checks it too: while it is paused the bot posts one notice a day in the channel and starts no turns there.
  • Refusals in a thread name the channel’s budget (“this channel’s monthly spend budget …”). A refusal for the member’s own budget or quota is posted there without its figures; the full text stays in their conversation.
FieldMeaning
periodday or month, both in UTC
limitUsdthe cap, a positive number
warnRatiofraction of the limit at which to warn, in (0, 1]; default 0.8

A user can have several budgets, for example a monthly user budget plus a daily budget on one role. Each applicable budget is checked, and any one at its limit refuses the run.

Spend is SUM(cost_log.total_cost) since the start of the current period. The same query drives enforcement and every screen, so a figure shown on a card is the figure the pause acts on.

Workspace attribution uses the agent’s workspace, falling back to the session’s workspace for rows without an agent. cost_log has no workspace column, so rows with neither count toward the user scope only, and a workspace budget can under-count them.

Cost is only as good as what the provider reports. Each cost_log row carries a costSource (src/db/cost-source.ts):

  • reported: USD from the provider.
  • estimated: USD computed from model pricing. Rows without a source predate the field and are treated as estimates.
  • unknown: CLI and subscription providers that report no cost. These calls are logged at $0.

The budget views report the estimated part of the spend and the number of unmeasured calls. When there are unmeasured calls this period, the meter says they “count as $0 — real spend may be higher”. A budget does not block on unknown cost; it simply under-counts work done through those providers.

checkSpend runs before an invocation:

  • when an agent is spawned (agent-manager);
  • before each LLM call in an agent worker;
  • before a CLI agent starts;
  • on each heartbeat tick. The tick checks only the user scope; role and workspace budgets are enforced when the tick’s turn spawns its agents.

The check happens before a call, not during it, so the call that crosses the limit completes and the next check refuses.

System and local principals (non-UUID user ids) have no budgets.

Budget rows and spend sums are cached in-process for 30 seconds. Another process may therefore see a change (a new limit, a cleared pause) up to 30 seconds late. A pause is never missed: a stale row still sees spend at or over the limit.

StateWhenWhat happens
okbelow the warn rationothing
warnedspend >= limit x warnRatioone spend_budget_warning notification per period; amber banner
pausedspend >= limit, or a pause stamped this periodone spend_budget_paused notification per period; runs refused; red banner

The warning and the pause are each recorded with a timestamp (warned_at, paused_at) claimed by a conditional update, so each notifies once per period even with several processes running. Notifications are created only by a real check (an agent run), never by reading the budget.

A refused run is not reported as a stop or a generic error. The agent row is marked failed with failureReason spend_budget, and the chat reply names the budget, the limit, the spend and the reset time, for example: “Agents are paused: your monthly spend budget of $25.00/month is reached ($25.40 spent this month). It resets 2026-10-01 00:00 UTC. Ask an admin to raise the limit.”

The stamps are compared against the current period start, so a stamp from an earlier period has no effect. A budget rolls over on its own at 00:00 UTC (daily) or 00:00 UTC on the first of the month (monthly), with no job involved. The resetsAt field in the API is the start of the next period.

Admins manage budgets at /admin/quotas, the “Quotas & budgets” tab, in the Spend budgets section below the quota table. Pick a user from the dropdown, or use the wallet button on that user’s quota row.

  • Add. “Add budget” opens a form: scope (all of the user’s agents, one agent role, or one workspace the user owns), period (per UTC day or month), limit in USD, and warn % (default 80).
  • Edit. Only the limit and warn % can change; scope and period identify the budget, so to change them add a new budget and delete the old one. Saving clears any warning and pause; the new limit is checked on the next agent run, which pauses again if spend is still over it.
  • Delete. Removes the budget after a confirm.
  • Resume. Shown on a paused budget. It clears the pause only. It helps once spend is back under the limit (costs corrected, or the limit raised); otherwise the next agent run pauses the budget again. When spend is still at or over the limit the section says so and suggests raising the limit with Edit instead, since saving also clears the pause.

Every change is written to the audit log as settings_changed with resource type spend_budget.

  • Banner. At the top of the app, polling /api/spend-budgets/me every 60 seconds. It is red when a budget is paused. A user-scope pause, or a pause of the workspace currently active, cannot be dismissed. A role pause or another workspace’s pause can be dismissed for the browser session. It is amber when a budget is at its warn ratio, and dismissible for the session; a new warning or a new period shows it again. Both link to the budgets card.
  • Dashboard card. A read-only “Spend budgets” card at /#budgets: for each budget, spend against the limit with a mark at the warn ratio, a state badge, the reset time in local time, and the estimated / unmeasured note. With no budget it says agents are not capped by cost.
  • Notifications. The inbox at /notifications has a “budgets” filter. Budget notifications show an icon, a label (“budget warning” or “budget reached · agents paused”), the spend-versus-limit line and a link to the card. They are tinted only while unread.
  • Chat. A refused turn renders a card, “Agents are paused — spend budget reached”, with the budget, limit, spend, reset time and a link to the card. The refusal is saved with the message, so the card survives a reload.

Both are per-user caps set on the same admin screen, but they are separate.

QuotasSpend budgets
Measurestokens per day, concurrent agents, API calls per minuteUSD from cost_log
ErrorQuotaExceededError (QUOTA_EXCEEDED)SpendBudgetExceededError (SPEND_BUDGET_EXCEEDED)
Scopethe useruser, role, workspace or group channel, per day or month
Chat”Quota reached” card”Agents are paused” card

Both are also distinct from an agent’s own per-spawn maxTokenBudget.

MethodPathPurpose
GET/api/spend-budgets/meThe caller’s budgets with this period’s spend, state and reset time. Read-only.
GET/api/admin/spend-budgetsAdmin: all budget rows; with ?userId=, also statuses (the same view the user sees).
PUT/api/admin/spend-budgetsAdmin: create or update by (user, scope, period); clears warning and pause.
DELETE/api/admin/spend-budgets/:idAdmin: delete a budget.
POST/api/admin/spend-budgets/:id/resumeAdmin: clear a budget’s pause.
GET/api/admin/users/:id/workspacesAdmin: workspaces a user owns, for the workspace scope picker.

The PUT body is { userId, scopeKind, scopeRef?, period, limitUsd, warnRatio? }. It returns 400 when limitUsd is not positive, warnRatio is outside (0, 1], scopeRef is missing for a role or workspace budget, or a workspace scopeRef is not a UUID; 404 when the user does not exist. For group_channel the scopeRef is the enrolment id (404 when there is none) and the budget is filed under the channel’s current owner whatever userId says. GET /api/admin/group-channels returns each channel’s budgets with their spend and state.


Edit this page in the repository.