Skip to main content

Cap Spend Per End User

A per-user product needs a per-user budget: "no single user costs me more than X a month", enforced no matter which agent they talk to. SOAT gets there in two moves — Actors give each end user an identity that usage events are billed to, and one actor-scoped Quota turns that ledger into a hard cap.

You will:

  1. Create an agent, then two actors standing in for two end users.
  2. Bind a session to an actor and run a turn — the session path is what makes attribution possible.
  3. Read spend per end user out of the usage meter.
  4. Cap every end user with a single quota, and watch one user get blocked with 429 while the other keeps working.
  5. Raise the cap, and see the blocked user resume.
  6. Discover the one case where a cost cap silently protects nothing — and the exception the platform files about it.
  7. Switch a quota to monitor mode to observe a breach without blocking it.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • Ollama running locally with qwen2.5:0.5b available. This tutorial uses a local provider so it runs without external credentials — to connect xAI, OpenAI, Anthropic, or Amazon Bedrock instead, see Connect Third-Party LLMs.
  • New to SOAT? Read Key Concepts to understand projects, agents, and sessions first.
  • CLI installed and configured, or SDK set up. See CLI or SDK.
  • For production hardening (secrets, env vars), see Configuration.
  • Server is at http://localhost:5047.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Quotas are always project-scoped, so the project is the tenant boundary for every cap in this tutorial.

PROJECT_ID=$(soat create-project --name "Per-User Spend" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create the provider and agent

A local Ollama AI provider and one agent that both end users will talk to. The instructions keep answers short so the token counts in this tutorial stay small and legible.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')

AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Support Bot" \
--instructions "You are a concise assistant. Answer in one short sentence." | jq -r '.id')
echo "AGENT_ID: $AGENT_ID"

Step 4 — Create an actor per end user

An Actor is the platform's identity for someone outside it — a customer, a WhatsApp contact, a signed-in app user. external_id is how you correlate it with your own system, and creation with an external_id is idempotent: posting the same one again returns the existing actor (200 instead of 201) rather than creating a duplicate. That is what lets an inbound-message webhook call create-actor on every message without bookkeeping.

ADA_ID=$(soat create-actor --project-id "$PROJECT_ID" \
--name "Ada" --external-id "+15551230001" | jq -r '.id')
BLAKE_ID=$(soat create-actor --project-id "$PROJECT_ID" \
--name "Blake" --external-id "+15551230002" | jq -r '.id')
echo "ADA_ID: $ADA_ID"
echo "BLAKE_ID: $BLAKE_ID"

Post Ada again and you get the same actor back, not a second one:

soat create-actor --project-id "$PROJECT_ID" \
--name "Ada" --external-id "+15551230001" | jq -r '.id'

Step 5 — Run a turn through a session bound to the actor

Attribution is set on the session path, because a session is the surface that knows which end user a turn belongs to. Create the session with actor_id, add a user message, and generate.

ADA_SESSION_ID=$(soat create-session --agent-id "$AGENT_ID" \
--actor-id "$ADA_ID" --name "Ada session" | jq -r '.id')

soat add-session-message --session-id "$ADA_SESSION_ID" \
--message "Name one use for a paperclip."
soat generate-session-response --session-id "$ADA_SESSION_ID" | jq '{status}'

Expected output (the assistant's wording will vary — only the status matters):

{ "status": "completed" }
note

Only the session path carries an end user. A direct agent generation, a trigger-initiated run, and an orchestration node have nobody behind them and record null for both actor_id and session_id — so they match no actor quota. Cap that traffic with a project- or agent-scoped quota instead.


Step 6 — Read spend per end user

The usage meter copies the actor and session onto every event at write time and freezes them there. Renaming an actor or deleting a session never rewrites recorded spend.

soat get-usage --project-id "$PROJECT_ID" --group-by actor | jq '{groups, totals}'

Expected output — one bucket per end user:

{
"groups": [
{
"key": "actor_...",
"cost_usd": null,
"input_tokens": 36,
"output_tokens": 14,
"cached_tokens": 0,
"reasoning_tokens": 0
}
],
"totals": {
"cost_usd": null,
"input_tokens": 36,
"output_tokens": 14,
"cached_tokens": 0,
"reasoning_tokens": 0
}
}

The raw event carries the full attribution chain, filterable by actor:

soat list-usage-meters --actor-id "$ADA_ID" \
| jq '.data[0] | {meter_type, model, actor_id, session_id, agent_id, cost_usd, components}'
{
"meter_type": "llm_tokens",
"model": "qwen2.5:0.5b",
"actor_id": "actor_...",
"session_id": "sess_...",
"agent_id": "agent_...",
"cost_usd": null,
"components": [
{ "component": "input_tokens", "quantity": 36, "unit": "token", "billable": true },
{ "component": "output_tokens", "quantity": 14, "unit": "token", "billable": true }
]
}

cost_usd is null because this project has no price book yet — the tokens are recorded, they just are not priced. Step 9 shows why that matters for cost caps.


Step 7 — One quota, one budget per end user

Here is the part worth reading twice. For actor scope, a null scope_ref means one budget per actor — not one pooled total shared across all of them. So a single quota expresses "every end user gets N tokens a month", and one user exhausting theirs never blocks anyone else.

The limit below is deliberately tiny (30 tokens) so that the single short turn from Step 5 already crosses it — the prompt alone costs more than that in input tokens. In production this would be 100000 or more.

QUOTA_ID=$(soat create-quota --project-id "$PROJECT_ID" \
--scope actor --metric tokens --window calendar_month --limit 30 | jq -r '.id')
soat get-quota --quota-id "$QUOTA_ID" \
| jq '{scope, scope_ref, metric, window, limit, mode}'

Expected output:

{
"scope": "actor",
"scope_ref": null,
"metric": "tokens",
"window": "calendar_month",
"limit": 30,
"mode": "enforce"
}
note

current_usage reads null on a tokens or cost_usd quota. Those metrics aggregate the usage meter directly at check time rather than keeping a separate counter, which is why a quota and the usage report can never disagree. Only the requests metric keeps a window counter.

A quota is also only accepted for a scope its metric can actually be aggregated by. requests is counted by the request middleware, which sees the API key but not the end user behind the call — so actor + requests is rejected outright rather than stored as a silent no-op:

# → expect-fail
soat create-quota --project-id "$PROJECT_ID" --scope actor --metric requests --window rolling_1h --limit 10
{
"status": 400,
"error": {
"code": "VALIDATION_FAILED",
"message": "scope \"actor\" is not valid for metric \"requests\"."
}
}

See Scope × metric validity for the full table.


Step 8 — One user is blocked, the other is not

Ada's Step 5 turn already put her over the 30-token cap, so her next turn is refused before the generation starts with 429 QUOTA_EXCEEDED — nothing is metered for a blocked call.

Blake, under the very same quota, is at zero. His first turn runs normally. That is the per-actor budget doing its job.

soat add-session-message --session-id "$ADA_SESSION_ID" --message "And another use?"
# → expect-fail
soat generate-session-response --session-id "$ADA_SESSION_ID"

Expected output — the error carries the quota that fired and when the window resets:

{
"status": 429,
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Quota exceeded for actor.",
"meta": {
"quota_id": "quota_...",
"metric": "tokens",
"limit": 30,
"window": "calendar_month",
"resets_at": "2026-08-01T00:00:00.000Z"
}
}
}

Now Blake, against the same quota:

BLAKE_SESSION_ID=$(soat create-session --agent-id "$AGENT_ID" \
--actor-id "$BLAKE_ID" --name "Blake session" | jq -r '.id')
soat add-session-message --session-id "$BLAKE_SESSION_ID" \
--message "Name one use for a rubber band."
soat generate-session-response --session-id "$BLAKE_SESSION_ID" | jq '{status}'
soat get-usage --project-id "$PROJECT_ID" --group-by actor | jq '.groups'
{ "status": "completed" }

Both users now appear as separate buckets — Ada capped, Blake spending:

[
{ "key": "actor_...", "input_tokens": 36, "output_tokens": 14, "cost_usd": null },
{ "key": "actor_...", "input_tokens": 36, "output_tokens": 20, "cost_usd": null }
]
note

A generation already in flight is never killed — its tokens are spent and will be billed — so a budget can overshoot by at most one generation. That is why Ada's Step 5 turn completed and then left her over the cap: the check compares the window's recorded usage before a generation starts, never mid-stream.


Step 9 — Raise the cap

limit and mode are the only mutable fields on a quota; scope, metric, and window are immutable, because changing them would silently redefine what the historical breaches meant. Raise the limit and Ada resumes immediately — the check reads the meter live, so there is no counter to reset.

soat update-quota --quota-id "$QUOTA_ID" --limit 100000 | jq '{limit, mode}'
soat generate-session-response --session-id "$ADA_SESSION_ID" | jq '{status}'

Expected output:

{ "limit": 100000, "mode": "enforce" }
{ "status": "completed" }

To give one named user a different allowance instead of the shared per-actor budget, set scope_ref to their actor id — that quota caps only that actor. Note it does not override the null-ref quota: every applicable quota is checked, and the tightest one that breaches wins.


Step 10 — A cost cap with no prices protects nothing

This is the one place a quota fails open, and the platform refuses to stay quiet about it. A cost_usd quota sums the priced cost of the window's events — but an event with no price-book row carries cost_usd: null and contributes 0. On a project with no prices (like this one), a cost_usd cap therefore never breaches and never blocks.

So when a cost check finds metered usage but nothing priced, it files a quota_unpriced exception, deduped on the quota — one dead cap is one triage item, and its occurrence_count is the number of generations that ran unprotected.

COST_QUOTA_ID=$(soat create-quota --project-id "$PROJECT_ID" \
--scope project --metric cost_usd --window calendar_month --limit 5 | jq -r '.id')

soat add-session-message --session-id "$BLAKE_SESSION_ID" --message "Another use?"
soat generate-session-response --session-id "$BLAKE_SESSION_ID" | jq '{status}'

soat list-exceptions --project-id "$PROJECT_ID" --kind quota_unpriced \
| jq '.data[0] | {kind, severity, status, title, occurrence_count, detail}'

Expected output — the generation is not blocked (the cap fails open), and the dead cap is filed for triage:

{ "status": "completed" }
{
"kind": "quota_unpriced",
"severity": "warning",
"status": "open",
"title": "Cost quota quota_... cannot be enforced: no priced usage in the window",
"occurrence_count": 1,
"detail": {
"quota_id": "quota_...",
"scope": "project",
"scope_ref": null,
"metric": "cost_usd",
"window": "calendar_month",
"limit": 5,
"unpriced_event_count": 2
}
}

Fix it by configuring the price book — walked through in Meter and Budget Your Project's Spend. A tokens quota has no such dependency: it sums component quantities, which are always recorded. When in doubt, cap tokens.


Step 11 — Observe before you enforce

Dropping a hard cap onto live traffic is how you find out your estimate was wrong at the worst moment. mode: monitor runs the identical check and records the breach — firing the quota.exceeded webhook and writing a quotas:MonitorBreach audit entry — but lets the request through.

soat update-quota --quota-id "$QUOTA_ID" --limit 1 --mode monitor | jq '{limit, mode}'

soat add-session-message --session-id "$ADA_SESSION_ID" --message "One more use?"
soat generate-session-response --session-id "$ADA_SESSION_ID" | jq '{status}'

Expected output — a 1-token cap that a real turn blows straight past, and the turn still completes:

{ "limit": 1, "mode": "monitor" }
{ "status": "completed" }

The breach is recorded once per window, with no principal — nobody requested it, so the platform records the fact rather than inventing an author:

soat list-audit-entries --project-id "$PROJECT_ID" --action "quotas:MonitorBreach" \
| jq '.data[0] | {action, resource_srn, principal_type, principal_id, detail}'
{
"action": "quotas:MonitorBreach",
"resource_srn": "soat:proj_...:quota:quota_...",
"principal_type": null,
"principal_id": null,
"detail": {
"kind": "quota_monitor_breach",
"quota_id": "quota_...",
"scope": "actor",
"scope_ref": null,
"metric": "tokens",
"window": "calendar_month",
"window_key": "2026-07",
"limit": 1,
"observed_value": 129
}
}

observed_value against limit is exactly the number you need to pick a real limit before flipping mode back to enforce.


How It Works

  • Three modules, three questions. Usage answers "what did this cost?". A Quota answers "has this scope exceeded its aggregate cap?". A Guardrail answers "may this one tool call execute?". A quota is cost control, not authorization — an over-budget caller gets 429, never 403.
  • The actor comes from the session, never the request. A caller cannot bill someone else's budget by passing an actor id or writing to tool_context; the session owns the actor link, so the actor a quota is enforced against is always the actor the resulting usage event is billed to.
  • Quotas read the meter, not a counter. tokens and cost_usd aggregate usage events at check time, which is why a quota and the usage report can never disagree, why raising a limit takes effect instantly, and why current_usage is null for them.
  • Per-actor is the only reading of a null scope_ref that isn't already spellable. A project-wide aggregate is exactly what a project quota expresses, so treating a null-ref actor quota as "all actors pooled" would make it a duplicate under a misleading name.
  • Fail-open is announced. The one case where a quota does not protect you — a cost cap over unpriced usage — files an exception instead of looking healthy through the API.

Next Steps