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.
Three budgets exist per session, and they're independent — set any combination, leave the rest unset.
| Total field | Used field | Unit |
|---|---|---|
budget_total_tokens | budget_used_tokens | LLM tokens |
budget_total_api_calls | budget_used_api_calls | Tool / API invocations |
budget_total_cost_cents | budget_used_cost_cents | Integer 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.
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:
turn 5: used 9000 / 10000 -> allow
turn 6: used 10800 / 10000 -> DENY - loop haltedNo 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.
# 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.
# 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:
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:
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)."
raiseFrequently 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.