Budgets are a security control, not a billing feature

Token and cost ceilings get filed under cost management, which is why they're usually configured by whoever owns the invoice and treated as an optimisation. They're a safety interlock, and they catch a failure mode that nothing else in the stack can see.

By Louis Bryson · 7 min read · Updated

Three budgets exist per session, and they're independent — set any combination, leave the rest unset.

Total fieldUsed fieldUnit
budget_total_tokensbudget_used_tokensLLM tokens
budget_total_api_callsbudget_used_api_callsTool / API invocations
budget_total_cost_centsbudget_used_cost_centsInteger cents

When any budget shows used >= total, the next governed event is denied. A budget counts as "set" when its total_ field is non-null; leave them all null and nothing is enforced.

python
from kitelogik import SessionContext

context = SessionContext(
    session_id="sess_001",
    user_role="support_agent",
    session_scopes=["read_customer", "approve_refund"],
    budget_total_tokens=100_000,
    budget_used_tokens=0,
    budget_total_api_calls=200,
    budget_used_api_calls=0,
    # cost cents omitted - no cost budget on this session
)

Why this is a security control

Consider what a runaway agent looks like to the rest of your stack. Each model call is well-formed. Each tool call is authorised and within scope. The credentials are valid, the arguments are sane, the session is legitimate. There is no signature to match, no anomaly to flag, and no adversary to detect — because there isn't one. The agent is looping on its own bad reasoning, or following an instruction it absorbed several turns ago, and doing so entirely within its permissions.

Every detection-based control needs to establish that something is wrong before it can act. Against this failure mode, none of them can.

A ceiling doesn't have that problem, because it isn't making a judgement. It fires on magnitude:

text
turn 5: used  9000 / 10000  -> allow
turn 6: used 10800 / 10000  -> DENY - loop halted

No diagnosis. No model in the decision path. No question of whether the agent was compromised or merely mistaken — which is useful precisely because you frequently can't tell the difference in the moment, and it doesn't change what should happen next.

The cost framing isn't wrong, it's just secondary. Denial-of-wallet is a real attack, and unbounded consumption is also the most reliable available signal that a loop has gone off the rails. The ceiling serves both.

Enforcement is opportunistic

The design detail that makes this practical: budget rules deny on two event types.

regoagent_budget.rego
# Explicit agent.budget events
deny if { input.event_type == "agent.budget"; _token_budget_exhausted }

# Opportunistic enforcement on every tool call
deny if { input.event_type == "tool_call"; _token_budget_exhausted }

The second rule is the one doing the work. You don't have to instrument a separate budget event stream — as long as SessionContext carries current counters, every governed tool call checks the limits regardless of which tool is being called. Budget enforcement rides along on governance you already have.

The counter is part of your trust boundary

Here is the caveat that deserves more attention than it usually gets: the gate trusts what you put in SessionContext. It does not independently observe token usage. Your runtime owns incrementing the counters, which means that code path is a security boundary, not bookkeeping.

python
# After a model interaction reports usage
context = context.model_copy(update={
    "budget_used_tokens":
        (context.budget_used_tokens or 0) + response.usage.total_tokens,
    "budget_used_api_calls":
        (context.budget_used_api_calls or 0) + 1,
})

Three failure modes follow from that, and all three are quiet.

Forgetting to update. Build a toolbox once at startup, never rebuild it with updated context, and the counters stay at their initial values forever. The budget never fires. Nothing errors — the system behaves exactly as though no budget were configured, which is the worst possible way for a safety control to fail.

Updating on the happy path only. If the increment happens after a successful call but is skipped when a call errors and retries, a loop that fails repeatedly — the loops most likely to run away — burns real tokens against a counter that barely moves.

Letting the agent influence it. If the counter is derived from anything the agent controls, it can be understated. Take the number from your provider's usage response, not from anything in the model's output.

Worth a test that asserts the counter actually moves after a call. It's two lines, and it's the difference between an enforced ceiling and a decorative one.

Per-role caps

Budgets on the session are a floor, not the whole story. Because denies merge across files in the same package — with the stricter rule winning — you can add role-based ceilings that apply regardless of what the session declares:

rego
package kitelogik.agent_budget

import future.keywords.if

# Cap guest role at $5 cost regardless of explicit budget
deny if {
    input.context.user_role == "guest"
    input.context.budget_used_cost_cents != null
    input.context.budget_used_cost_cents > 500
}

# Cap untrusted-tier sessions at 50K tokens
deny if {
    input.context.user_role in {"guest", "anonymous"}
    input.context.budget_used_tokens != null
    input.context.budget_used_tokens > 50000
}

This is the useful shape for anything user-facing: a generous default for authenticated users, a hard ceiling for anonymous ones, and neither depending on the calling code remembering to set a budget.

What the gate does not do

  • Increment counters for you. Your runtime owns it, and the gate trusts the result.
  • Enforce across sessions. Budgets are per session. Org-wide caps mean aggregating in your orchestrator and passing the total in.
  • Warn before exhaustion. Allow or deny, nothing between. Check the ratio yourself if you want a warning.

Catch the denial and turn it into something a person can act on, rather than a stack trace:

python
from kitelogik import GovernanceError

try:
    result = await toolbox.call("approve_refund", {...})
except GovernanceError as exc:
    if "budget" in exc.decision.reason.lower():
        return f"Session budget exhausted ({context.budget_used_tokens} tokens used)."
    raise

Frequently asked questions

Why call a spend limit a security control?

Because of when it fires. Detection-based controls need to establish that something is wrong before acting, and a runaway agent produces no signature — every individual call is well-formed and authorised. A ceiling fires on magnitude alone. It stops a loop without anyone diagnosing why the loop is running, which is the property that matters when the alternative is discovering it from an invoice.

Do I have to fire explicit agent.budget events?

No, and this is the design detail worth knowing. Budget rules deny on two event types: explicit agent.budget events, and opportunistically on every tool_call. As long as SessionContext carries up-to-date used counters, every governed tool call checks the limits regardless of which tool is being called. You get enforcement without instrumenting a separate event stream.

Who is responsible for incrementing the counters?

Your runtime, and this is the most important caveat in the whole design. The gate trusts what you put in SessionContext — it does not observe token usage independently. That makes the counter update path part of your trust boundary. If it can be skipped, bypassed, or influenced by the agent, the budget becomes advisory rather than enforced.

Can I set an org-wide budget across many agent sessions?

Not with the bundled policy, which is per session. Aggregating spend across sessions for an organisation-wide cap is your orchestrator's job: compute the aggregate yourself and pass it into each SessionContext, and the budget policy will enforce against it. The gate has no cross-session view on its own.

Can I warn before hitting the limit instead of hard-stopping?

Not from the gate — it is allow-or-deny with no in-between. Soft warnings belong in your runtime, where you already have both numbers and can check the ratio before calling. The gate is the backstop, not the notification system.

Louis Bryson
Founder & maintainer, Kite Logik

Engineer focused on production AI agent infrastructure and policy-as-code. Maintains Kite Logik, the open-source OPA/Rego governance layer for Python agents.

Connect on LinkedIn