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 bind sessions to two actors, read spend per end user, cap every user with a single quota (one blocked with 429, one unaffected), raise the cap, see the one case where a cost cap silently protects nothing (and the exception it files), and finish with monitor mode.

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 an end user. Creation with an external_id is idempotent: posting the same one again returns the existing actor (200 instead of 201), so an inbound-message webhook can 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 --wait true --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-aggregate --project-id "$PROJECT_ID" --group-by actor | jq '{groups, totals}'

Expected output — one bucket per end user:

{
"groups": {
"data": [
{
"key": "actor_...",
"cost_usd": null,
"event_count": 1,
"input_tokens": 36,
"output_tokens": 14,
"cached_tokens": 0,
"reasoning_tokens": 0
}
],
"total": 1,
"limit": 50,
"offset": 0
},
"totals": {
"cost_usd": null,
"event_count": 1,
"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-events --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

For actor scope, a null scope_ref means one budget per actor — not one pooled total. 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 Step 5's single turn already crosses it; 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 at check time rather than keeping a counter. Only requests keeps a window counter.

A quota is only accepted for a scope its metric can be aggregated by — 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. Blake, under the same quota, is at zero and runs normally.

soat add-session-message --session-id "$ADA_SESSION_ID" --message "And another use?"
# → expect-fail
soat generate-session-response --wait true --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 --wait true --session-id "$BLAKE_SESSION_ID" | jq '{status}'
soat get-usage-aggregate --project-id "$PROJECT_ID" --group-by actor | jq '.groups.data'
{ "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, so a budget can overshoot by at most one generation — the check runs before a generation starts, never mid-stream.


Step 9 — Raise the cap

limit and mode are the only mutable fields on a quota. 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 --wait true --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

A cost_usd quota sums priced costs — an event with no price-book row contributes 0, so on a project with no prices (like this one) a cost_usd cap fails open: it never breaches. When a cost check finds AI usage the price book did not cover, it files a quota_unpriced exception naming the rows to price, deduped on the quota. A project that prices some of its models files the same item, for the same reason: the cap is measuring less than it caps.

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 --wait true --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: the window metered usage no price row covered",
"occurrence_count": 1,
"detail": {
"quota_id": "quota_...",
"scope": "project",
"scope_ref": null,
"metric": "cost_usd",
"window": "calendar_month",
"limit": 5,
"metered_event_count": 2,
"unpriced_event_count": 2,
"unpriced_rows": [
{ "provider": "ollama", "model": "qwen2.5:0.5b", "component": "input_tokens" },
{ "provider": "ollama", "model": "qwen2.5:0.5b", "component": "output_tokens" }
]
}
}

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

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 --wait true --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": "srn: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.


Next Steps