Skip to main content

Cap Spend Per End User

Actors give each end user an identity that usage events are billed to; one actor-scoped Quota caps each of them.

You bind sessions to two actors, read spend per user, cap every user with one quota (one blocked with 429, one unaffected), raise the cap, see the case where a cost cap protects nothing (and the exception it files), and finish with monitor mode.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

See Users for authentication.

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

Step 2 — Create a project​

Quotas are project-scoped; the project is the tenant boundary.

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 for both end users. Short answers keep token counts 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 identity of an end user. Creation with an external_id is idempotent: the same one again returns the existing actor (200 instead of 201), so a webhook can call create-actor on every message.

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:

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. 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 (wording varies; 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 record null for actor_id and session_id and match no actor quota; cap them with a project- or agent-scoped quota.


Step 6 — Read spend per end user​

The usage meter freezes actor and session onto every event at write time; 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}'

One bucket per end user:

{
"groups": {
"data": [
{
"key": "actor_...",
"cost_usd": null,
"event_count": 1,
"input_tokens": 36,
"output_tokens": 14,
"cached_tokens": 0,
"cache_write_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,
"cache_write_tokens": 0,
"reasoning_tokens": 0
}
}

The raw event carries the 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: the project has no price book yet. Step 10 shows why that matters.


Step 7 — One quota, one budget per end user​

For actor scope, a null scope_ref means one budget per actor, not a pooled total; one user exhausting theirs never blocks another (Quotas).

The 30-token limit is tiny so Step 5's turn already crosses it; production would use 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 is null on tokens and cost_usd quotas: they aggregate the usage meter at check time. Only requests keeps a window counter.

A metric that cannot be aggregated by the scope is rejected — actor + requests:

# → 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\"."
}
}

Full table: Scope × metric validity.


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

Ada is over the 30-token cap, so her next turn is refused before the generation starts with 429 QUOTA_EXCEEDED (Quotas). Blake 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"

The error carries the quota that fired and the window reset:

{
"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"
}
}
}

Blake, 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" }

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

The check runs before a generation starts, never mid-stream, so a budget can overshoot by at most one generation.


Step 9 — Raise the cap​

limit and mode are the only mutable fields on a quota. The check reads the meter live, so Ada resumes immediately.

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" }

For a per-user allowance, set scope_ref to the actor id. It does not override the null-ref quota: every applicable quota is checked and the tightest breach wins.


Step 10 — A cost cap with no prices protects nothing​

An event with no price-book row contributes 0 to a cost_usd quota, so on an unpriced project the cap fails open. A cost check that finds unpriced AI usage files a quota_unpriced exception naming the rows to price, deduped on the quota; a partially priced project files the same item.

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}'

The generation is not blocked; 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: configure the price book (Meter and Budget Your Project's Spend). A tokens quota has no such dependency; when in doubt, cap tokens.


Step 11 — Observe before you enforce​

mode: monitor runs the same check, fires the quota.exceeded webhook, writes a quotas:MonitorBreach audit entry, and 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}'

A 1-token cap is exceeded and the turn still completes:

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

The breach is recorded once per window, with no principal:

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 sizes a real limit before flipping mode back to enforce.


Next Steps​