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.5bavailable. 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.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
import { SoatClient } from '@soat/sdk';
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.
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Quotas are always project-scoped, so the project is the tenant boundary for every cap in this tutorial.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Per-User Spend" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Per-User Spend' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
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"
const { data: provider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: PROJECT_ID,
ai_provider_id: provider.id,
name: 'Support Bot',
instructions: 'You are a concise assistant. Answer in one short sentence.',
},
});
const AGENT_ID = agent.id;
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Local Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" | jq -r '.id')
AGENT_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"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.
- CLI
- SDK
- curl
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'
const { data: ada } = await adminSoat.actors.createActor({
body: { project_id: PROJECT_ID, name: 'Ada', external_id: '+15551230001' },
});
const { data: blake } = await adminSoat.actors.createActor({
body: { project_id: PROJECT_ID, name: 'Blake', external_id: '+15551230002' },
});
// Idempotent: same external_id returns the same actor.
const { data: again } = await adminSoat.actors.createActor({
body: { project_id: PROJECT_ID, name: 'Ada', external_id: '+15551230001' },
});
console.log(again.id === ada.id); // true
ADA_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/actors" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Ada\",\"external_id\":\"+15551230001\"}" | jq -r '.id')
BLAKE_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/actors" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Blake\",\"external_id\":\"+15551230002\"}" | jq -r '.id')
echo "ADA_ID: $ADA_ID"
echo "BLAKE_ID: $BLAKE_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.
- CLI
- SDK
- curl
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" }
const { data: adaSession } = await adminSoat.sessions.createSession({
body: { agent_id: AGENT_ID, actor_id: ada.id, name: 'Ada session' },
});
await adminSoat.sessions.addSessionMessage({
path: { session_id: adaSession.id },
body: { message: 'Name one use for a paperclip.' },
});
const { data: turn } = await adminSoat.sessions.generateSessionResponse({
path: { session_id: adaSession.id },
query: { wait: true },
body: {},
});
console.log(turn.status); // completed
ADA_SESSION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"agent_id\":\"$AGENT_ID\",\"actor_id\":\"$ADA_ID\",\"name\":\"Ada session\"}" | jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/messages" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"message":"Name one use for a paperclip."}' > /dev/null
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{status}'
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.
- CLI
- SDK
- curl
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.
const { data: byActor } = await adminSoat.usage.getUsageAggregate({
params: { query: { project_id: PROJECT_ID, group_by: 'actor' } },
});
console.log(byActor.groups.data, byActor.groups.total);
const { data: meters } = await adminSoat.usage.listUsageEvents({
params: { query: { actor_id: ada.id } },
});
console.log(meters.data[0].actor_id, meters.data[0].components);
curl -s "$SOAT_BASE_URL/api/v1/usage/aggregate?project_id=$PROJECT_ID&group_by=actor" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{groups, totals}'
curl -s "$SOAT_BASE_URL/api/v1/usage/events?actor_id=$ADA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
| jq '.data[0] | {meter_type, actor_id, session_id, components}'
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.
- CLI
- SDK
- curl
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"
}
const { data: quota } = await adminSoat.quotas.createQuota({
body: {
project_id: PROJECT_ID,
scope: 'actor',
metric: 'tokens',
window: 'calendar_month',
limit: 30,
},
});
console.log(quota.scope, quota.scope_ref, quota.mode); // actor null enforce
QUOTA_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/quotas" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"scope\":\"actor\",\"metric\":\"tokens\",\"window\":\"calendar_month\",\"limit\":30}" \
| jq -r '.id')
curl -s "$SOAT_BASE_URL/api/v1/quotas/$QUOTA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '{scope, scope_ref, metric, window, limit, mode}'
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:
- CLI
- SDK
- curl
# → 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.
const { error } = await adminSoat.quotas.createQuota({
body: {
project_id: PROJECT_ID,
scope: 'actor',
metric: 'requests',
window: 'rolling_1h',
limit: 10,
},
});
console.log(error); // 400 — scope "actor" is not valid for metric "requests".
curl -s -X POST "$SOAT_BASE_URL/api/v1/quotas" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"scope\":\"actor\",\"metric\":\"requests\",\"window\":\"rolling_1h\",\"limit\":10}" \
| jq '{error}'
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.
- CLI
- SDK
- curl
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 }
]
await adminSoat.sessions.addSessionMessage({
path: { session_id: adaSession.id },
body: { message: 'And another use?' },
});
const { error } = await adminSoat.sessions.generateSessionResponse({
path: { session_id: adaSession.id },
query: { wait: true },
body: {},
});
console.log(error.error.code, error.error.meta.resets_at); // QUOTA_EXCEEDED ...
const { data: blakeSession } = await adminSoat.sessions.createSession({
body: { agent_id: AGENT_ID, actor_id: blake.id, name: 'Blake session' },
});
await adminSoat.sessions.addSessionMessage({
path: { session_id: blakeSession.id },
body: { message: 'Name one use for a rubber band.' },
});
const { data: blakeTurn } = await adminSoat.sessions.generateSessionResponse({
path: { session_id: blakeSession.id },
query: { wait: true },
body: {},
});
console.log(blakeTurn.status); // completed — a separate budget
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/messages" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"message":"And another use?"}' > /dev/null
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{error}'
BLAKE_SESSION_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"agent_id\":\"$AGENT_ID\",\"actor_id\":\"$BLAKE_ID\",\"name\":\"Blake session\"}" | jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$BLAKE_SESSION_ID/messages" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"message":"Name one use for a rubber band."}' > /dev/null
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$BLAKE_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{status}'
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.
- CLI
- SDK
- curl
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" }
await adminSoat.quotas.updateQuota({
path: { quota_id: quota.id },
body: { limit: 100000 },
});
const { data: resumed } = await adminSoat.sessions.generateSessionResponse({
path: { session_id: adaSession.id },
query: { wait: true },
body: {},
});
console.log(resumed.status); // completed
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/quotas/$QUOTA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"limit":100000}' | jq '{limit, mode}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{status}'
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.
- CLI
- SDK
- curl
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.
const { data: costQuota } = await adminSoat.quotas.createQuota({
body: {
project_id: PROJECT_ID,
scope: 'project',
metric: 'cost_usd',
window: 'calendar_month',
limit: 5,
},
});
await adminSoat.sessions.addSessionMessage({
path: { session_id: blakeSession.id },
body: { message: 'Another use?' },
});
await adminSoat.sessions.generateSessionResponse({
path: { session_id: blakeSession.id },
query: { wait: true },
body: {},
});
const { data: filed } = await adminSoat.exceptions.listExceptions({
params: { query: { project_id: PROJECT_ID, kind: 'quota_unpriced' } },
});
console.log(filed.data[0].detail.unpriced_event_count);
COST_QUOTA_ID=$(curl -s -X POST "$SOAT_BASE_URL/api/v1/quotas" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"scope\":\"project\",\"metric\":\"cost_usd\",\"window\":\"calendar_month\",\"limit\":5}" \
| jq -r '.id')
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$BLAKE_SESSION_ID/messages" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"message":"Another use?"}' > /dev/null
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$BLAKE_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{status}'
curl -s "$SOAT_BASE_URL/api/v1/exceptions?project_id=$PROJECT_ID&kind=quota_unpriced" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data[0] | {kind, severity, title, detail}'
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.
- CLI
- SDK
- curl
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.
await adminSoat.quotas.updateQuota({
path: { quota_id: quota.id },
body: { limit: 1, mode: 'monitor' },
});
await adminSoat.sessions.addSessionMessage({
path: { session_id: adaSession.id },
body: { message: 'One more use?' },
});
const { data: allowed } = await adminSoat.sessions.generateSessionResponse({
path: { session_id: adaSession.id },
query: { wait: true },
body: {},
});
console.log(allowed.status); // completed — monitor mode does not block
const { data: audit } = await adminSoat.auditLog.listAuditEntries({
params: {
query: { project_id: PROJECT_ID, action: 'quotas:MonitorBreach' },
},
});
console.log(audit.data[0].detail.observed_value, audit.data[0].detail.limit);
curl -s -X PATCH "$SOAT_BASE_URL/api/v1/quotas/$QUOTA_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"limit":1,"mode":"monitor"}' | jq '{limit, mode}'
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/messages" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"message":"One more use?"}' > /dev/null
curl -s -X POST "$SOAT_BASE_URL/api/v1/sessions/$ADA_SESSION_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{}' | jq '{status}'
curl -s "$SOAT_BASE_URL/api/v1/audit-log?project_id=$PROJECT_ID&action=quotas:MonitorBreach" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq '.data[0] | {action, principal_type, detail}'
Next Steps
- Price your usage so
cost_usdcaps actually bite, and get pushed a webhook before a budget is hit, in Meter and Budget Your Project's Spend. - Give each end user their own long-term memory: create one memory per user, keep the mapping in your application, and pass it as
knowledge_config.memory_idson the generate call — see Give Your Agent Long-Term Memory and Actors — Per-Actor Memory. - Cap an individual tool call rather than aggregate spend with Gate a Dangerous Tool with Guardrails.
- Triage what a breached cap filed — see Exceptions.