Governance & Cost Control
AgentWatch’s governance model treats AI spend and AI access as things to be controlled, not merely reported. The distinction matters: a monthly report tells you what already happened, while an enforced budget stops it.
How do budgets work?
Section titled “How do budgets work?”Budgets are set at four tiers — organization, team, employee, and individual user API key — with independent daily and monthly limits at each level, and every tier’s spend is tracked continuously.
Two of those tiers enforce at the gateway, rejecting the request with 402 BUDGET_EXCEEDED rather than letting spend continue:
| Tier | Tracked | Enforced at the gateway |
|---|---|---|
| Employee (tenant) | Yes | Yes, always |
| Team | Yes | Yes, when Enforce team budgets is enabled (see below) |
| Organization | Yes | Not yet — reporting and alerts only |
| User API key | Yes | Reporting and alerts only |
Where two enforcing tiers both apply, the strictest wins: the employee’s own limit is evaluated first, then their team’s, and the first one that is exhausted refuses the request. Alongside enforcement:
- Threshold alerts fire by webhook and email at 80%, 90%, and 100% of each tier’s daily and monthly budget, with configurable recipients per tier.
- Admin overrides carry an expiry — extra headroom can be granted for a known launch or migration without anyone having to remember to revoke it.
- Efficiency leaderboards rank teams by tokens-per-dollar and month-over-month spend trend, which is what makes FinOps review of AI usage possible at all.
What can be rate-limited?
Section titled “What can be rate-limited?”Every dimension the gateway can see: per model, per user, per team, per organization, per API key, per application, per MCP server, and per individual tool. On breach, the configured behavior is selectable per scope — fall through to the next-priority provider, return a 429 with a retry-after, or raise a webhook or email alert. Concurrency caps, queue depth, and queue timeouts are configurable so a burst is absorbed rather than turned into failures.
Can spend be attributed to individual employees?
Section titled “Can spend be attributed to individual employees?”Yes, and each employee is a first-class identity rather than a tag on a log line. Per employee, AgentWatch supports:
- A profile with department, team, role, and status.
- A scoped API key inheriting the organization’s model access, budget, and DLP policy.
- Personal daily and monthly budgets applied as a stricter cap than the team’s.
- Pause and resume of access — for a departure, suspension, or investigation — without destroying the audit trail.
- Forced key rotation with email notification and an overlap window.
- CSV bulk import so hundreds of employees can be onboarded from an HR export.
- Automatic task categorization, classifying each request into Code, Content, Data Analysis, or a custom category — which is how a report can answer “how much of last quarter’s AI spend went to code generation versus marketing copy?”
How is chargeback handled?
Section titled “How is chargeback handled?”Every request records tokens, cost, duration, model, provider, team, user, and session, so spend rolls up along any of those dimensions for internal chargeback or showback. Reports export from the UI and programmatically for finance systems, and cached-token counts are surfaced separately so savings from prompt caching are visible rather than hidden in a lower total.
Is AgentWatch multi-tenant?
Section titled “Is AgentWatch multi-tenant?”Natively. Each organization is a fully isolated tenant — providers, keys, model catalogs, employees, teams, budgets, DLP policies, audit trails, and analytics are all tenant-scoped at the data layer, with no cross-organization access path. A platform-level super administrator can see across organizations; an organization administrator sees only their own. The hierarchy runs organization → teams → tenants/users → model access and API keys, with per-tenant rate limits, token limits, budgets, suspension, and feature flags.
This isolation model is what makes AgentWatch deployable not just internally but as a governed AI gateway offered to others — by ISVs, managed service providers, or a holding company serving its operating units.
Can models be restricted per team?
Section titled “Can models be restricted per team?”Yes. Administrators provision which models are exposed to which teams and tenants, in bulk if needed, and can alias models per organization for governance naming. A model can be disabled to retire it without breaking applications that still reference it.
How do I set a budget?
Section titled “How do I set a budget?”Budgets are set per organization, team, employee or API key. Of these, the employee and team tiers are the ones the gateway enforces (team enforcement is opt-in — see the next section); the strictest enforcing limit wins. Set organization defaults first, then override the exceptions.
Console. Budget Management → pick the scope → set a daily and monthly limit. New employees inherit the organization default.
API.
# organization-wide defaults, applied to employees created from now oncurl -s -X POST https://<your-agentwatch-host>/admin/company/defaults/budget \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"defaultDailyBudget": 10, "defaultMonthlyBudget": 300}'
# one employeecurl -s -X POST https://<your-agentwatch-host>/admin/budget/limits/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"dailyBudgetUSD": 25, "monthlyBudgetUSD": 500}'Verify. Read the live counters back — this is also what you poll if you want your own alerting:
curl -s https://<your-agentwatch-host>/admin/budget/status/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>"How do I make a team budget actually block?
Section titled “How do I make a team budget actually block?”Team budgets are tracked for every deployment, but enforcing them is opt-in per organization. That default is deliberate: every team already carries a daily and monthly limit (50 / 1500 by default) that many deployments have only ever used for reporting, so switching enforcement on globally would start refusing traffic against numbers nobody chose. Review your team limits before you turn it on.
Console. Settings → Monitoring & Alerts → tick Enforce team budgets. Review each team’s limits first in Teams → the team → Budget: the dialog shows both the Team Daily Budget ($) and the Team Monthly Budget ($), with today’s team spend. The daily limit is usually the one a busy team reaches first.
API.
# 1. check what the team's limits and spend actually arecurl -s https://<your-agentwatch-host>/admin/api/teams/<teamId>/budget \ -H "Authorization: Bearer <tenant-admin JWT>"# → {"monthlyBudget":1500,"spent":412.88,"remaining":1087.12,# "dailyBudget":50,"dailyRemaining":31.40,"warningLevel":"none", ...}
# 2. set them where you want themcurl -s -X POST https://<your-agentwatch-host>/admin/api/teams/<teamId>/budget/limits \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"dailyBudgetUSD": 100, "monthlyBudgetUSD": 2000}'
# 3. then turn enforcement on for the organizationcurl -s -X POST "https://<your-agentwatch-host>/admin/company/settings?organizationId=<org id>" \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"teamBudgetEnforcement": true}'Verify. Settings are cached for 30 seconds, so wait a moment, then send a request as a member of a team you have deliberately capped low. You should see:
{"error":{"code":"BUDGET_EXCEEDED","message":"Budget limit exceeded: Team \"Engineering\" monthly budget exceeded ($2004.10 / $2000.00)"}}The message names the team, so a member who is comfortably inside their own
personal budget is not sent looking at the wrong screen. The rejection is also
written to the usage table as a blocked row with blockCode: budget_exceeded
and zero cost, so it reports beside guardrail blocks.
This applies to every identity the team contains, including employees created automatically by a service key using delegated identity: an auto-created employee joins the service account’s team, and is governed by that team’s budget from its first request.
How do I cap tokens rather than dollars?
Section titled “How do I cap tokens rather than dollars?”Set a token limit. If your contracts are written in tokens rather than
dollars, this is the control you want: it refuses with 402 TOKEN_LIMIT_EXCEEDED, deliberately a different code from BUDGET_EXCEEDED, so
“out of tokens” and “out of money” stay separate conversations with your own
customer. Limits exist daily and monthly, at both the employee and the
team tier, and are enforced by the same gate as dollar budgets — employee
dollars, then employee tokens, then team dollars, then team tokens; the first
one exceeded refuses.
⚠ Nothing is limited until you set a number. Every employee and team starts
with no token limit, and 0 also means unlimited — it is not “block
everything”.
Console — employee. Budget Management → the Token Limit column. Click it to edit, enter a daily and/or monthly ceiling, and leave a field blank for no limit. The column shows consumption against each ceiling with the same colour thresholds as the spend bars, so someone approaching a limit is visible before they are refused. An employee with no limit shows what they have used and the words no limit.
Console — team. Teams → the team’s Budget dialog → the daily and monthly token ceilings beside the dollar budgets, with a usage bar. Team enforcement follows the same Enforce team budgets switch as team dollar budgets (previous section).
The budget overview carries a token tile: total tokens this month and how many employees a limit actually covers. If it reads No limits set, nothing is being enforced — which is different from nobody being near a limit.
API.
# one employee — daily and monthly ceilingscurl -s -X PUT https://<your-agentwatch-host>/admin/tenants/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"dailyTokenLimit": 200000, "monthlyTokenLimit": 5000000}'
# one team — dollars and tokens togethercurl -s -X POST https://<your-agentwatch-host>/admin/api/teams/<teamId>/budget/limits \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"dailyBudgetUSD": 100, "monthlyBudgetUSD": 2000, "dailyTokenLimit": 500000, "monthlyTokenLimit": 10000000}'Send null to remove a limit (an empty field in the console does this for
you). ⚠ Before 2.0.272 a cleared limit reported success and stayed in force; if
you have ever tried to remove one and found it still applied, clear it once more
on 2.0.272 and it will take effect.
Verify. Send a request as someone you have deliberately capped low:
{"error":{"code":"TOKEN_LIMIT_EXCEEDED","message":"Token limit exceeded: monthly token limit exceeded (23,131 / 1,000 tokens)"}}The message names both numbers. Like a budget block, the rejection is written to the usage table as a blocked row with zero cost, so refusals report beside guardrail blocks rather than existing only in a log.
| Symptom | Cause | Fix |
|---|---|---|
Usage far past the limit, still served 200 |
No limit is set — the field is blank or 0, both of which mean unlimited |
Enter a number in Budget Management → Token Limit |
| A team is over its token ceiling, nothing is refused | Team enforcement is off | Settings → Monitoring & Alerts → Enforce team budgets |
| A cleared limit still refuses | Fixed in 2.0.272; a clear before that release did not save | Clear the field again on 2.0.272 |
402 TOKEN_LIMIT_EXCEEDED on an account you thought was uncapped |
A team ceiling, not theirs — the message names the team | Raise the team limit, or grant a time-boxed override (next section) |
How do I unblock someone who has hit their limit?
Section titled “How do I unblock someone who has hit their limit?”Grant a time-boxed override rather than raising the limit permanently — it expires by itself, so nobody has to remember to put it back. An override adds its amount to both the daily and the monthly limit until it expires (1–168 hours).
First, find out WHICH limit is refusing them. In Budget Management, an
employee refused by their team shows Blocked by team
Employee override — console. Budget Management → the employee’s row → Override. The form shows how far over they are and pre-fills a suggested amount. ⚠ An override exactly equal to the overage leaves them at $0 remaining, which is still refused — the suggestion adds a margin (10%, at least $1) for that reason.
curl -s -X POST https://<your-agentwatch-host>/admin/budget/override/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"overrideAmount": 50, "durationHours": 24, "reason": "quarter-end close"}'
# and to revoke it earlycurl -s -X DELETE https://<your-agentwatch-host>/admin/budget/override/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>"Team override — console. Teams → the team → Budget → Team Emergency Override (amount, hours, reason; Clear Override removes it). The same dialog has the team’s Daily Budget ($) beside the monthly one.
curl -s -X POST https://<your-agentwatch-host>/admin/api/teams/<teamId>/budget/override \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"overrideAmount": 25, "durationHours": 8, "reason": "launch day"}'
curl -s -X DELETE https://<your-agentwatch-host>/admin/api/teams/<teamId>/budget/override \ -H "Authorization: Bearer <tenant-admin JWT>"Every override granted or cleared is written to Settings → Audit Logs
(budget.override_set / budget.override_cleared, and the team_budget.*
equivalents). An administrator can only override employees and teams in their
own organization — another organization’s returns 403.
Verify. GET /admin/budget/status/<tenantId> should now show
"warningLevel": "normal" (or warning), and its team object
"blocked": false:
curl -s https://<your-agentwatch-host>/admin/budget/status/<tenantId> \ -H "Authorization: Bearer <tenant-admin JWT>"# → {"data":{"dailySpent":3.1,"dailyBudget":60,"warningLevel":"normal", ...,# "team":{"name":"Support","enforced":true,"blocked":false,# "dailySpent":48.2,"dailyLimit":75,"blockedPeriods":[],# "override":{"amount":25,"active":true,"expiry":"..."}}}}| Symptom | Cause | Fix |
|---|---|---|
| Extended the budget but still blocked | (1) The team is over its limit — usually the daily $50 default — and team enforcement is on; the employee’s own row looked fine. (2) The override equals the overage exactly, leaving $0 remaining. (3) Before 2.0.363, a request in flight while you saved could re-cache the old “blocked” for 30 seconds. | (1) Look for Blocked by team on the row; raise the team’s Daily Budget or apply a Team Emergency Override. (2) Use the suggested amount (overage plus a margin). (3) Upgrade to 2.0.363, or wait 30 seconds. The 402 details.blockers list names every limit refusing the request. |
| Raised one limit, refused by another | More than one limit applies (employee and team, or dollars and tokens) | Read details.blockers in the 402 — one entry per limit, with scope, period, limitType, spent and limit |
402 PLAN_LIMIT_EXCEEDED after a blocked employee’s tool kept retrying |
Before 2.0.363 refused requests counted toward the plan’s API-call allowance | Upgrade to 2.0.363 — only served requests count now |
Override returns 403 |
The employee or team belongs to another organization | Sign in to that organization’s console |
How do I see where the money went?
Section titled “How do I see where the money went?”Usage Insights shows spend by employee, team, model and provider over any window. For chargeback, pull the same numbers as data:
curl -s "https://<your-agentwatch-host>/admin/budget/report?window=30d" \ -H "Authorization: Bearer <tenant-admin JWT>"
# spend attributed by any custom tag your applications sendcurl -s "https://<your-agentwatch-host>/admin/analytics/usage-by-tag?key=cost-center" \ -H "Authorization: Bearer <tenant-admin JWT>"Tag headers (x-aw-tag-<key>) need no configuration on the client side; register
the header names you want captured under Settings → Usage Tags and they
become groupable dimensions.
Which calls count toward budgets and token limits?
Section titled “Which calls count toward budgets and token limits?”Every call AgentWatch serves on its own provider credentials is metered
from the token counts the provider reports, and priced from the model catalog.
That includes streamed calls: since 2.0.371 the gateway records the
provider’s own counts from the stream. For OpenAI-shaped APIs it asks for them
with stream_options.include_usage. Pass-through calls are metered too:
/v1/responses and native Gemini since 2.0.370, and Cohere /v1/generate and
/v1/embed since 2.0.371.
When a provider reports no usage, AgentWatch records an estimate (about four
characters per token) so the call still counts, and marks the row
tokensEstimated. Anthropic’s legacy /v1/complete is always estimated,
because it sends no token counts; use /v1/messages for exact counts.
| You see | Why | What to do |
|---|---|---|
Streamed rows before 2.0.371, or /v1/responses rows before 2.0.370, have different token counts from your provider’s bill |
Those versions recorded an estimate, or 0 | Nothing for new calls. Old rows are not rewritten. |
A row is marked tokensEstimated |
The provider sent no usage for that call | Expected for /v1/complete. For a chat stream, check the gateway log for stream-include-usage-refused: that upstream refused to report usage. |
| A row has tokens but cost 0 and is marked unpriced | The model has no price in the catalog | Set the input and output price on the model in Model Catalog (console sidebar) |
| A failed call shows cost 0 | The provider refused it, so nothing was served | Expected |
A /v1/messages row is marked cancelled |
The client disconnected mid-answer; the tokens already generated are recorded | Expected |
| AgentWatch Connect traffic shows cost 0 | The employee’s own account pays the provider, not your gateway keys | Expected; tokens are still recorded |
Anthropic prompt-cache tokens are not yet added to the recorded input count.
How do I configure rate limits?
Section titled “How do I configure rate limits?”Rate limits are requests per minute, checked for every gateway request before it reaches a provider. Four limits apply, and a request must be inside all of them:
| Limit | Where to set it | Default |
|---|---|---|
| Per user — each employee | Settings → Rate Limits (perUserRateLimit) |
300 |
| One employee’s own limit — replaces per user for that employee | Employee Management → Edit → Max Requests/Min, or the Rate Limit column in Budget Management | not set |
| Per API key | Settings → Rate Limits (perApiKeyRateLimit, 0 = off) |
off |
| Whole organization — all employees together | Settings → Rate Limits (globalRateLimit) |
1500 |
An employee’s own limit can be lower than the organization’s per-user limit
(to slow one person down) or higher (for a heavy user). Leave it blank or 0
to use the organization’s per-user limit — emptying the box and saving removes
an employee’s limit (the same goes for their Monthly Token Limit). The
per-API-key and organization limits still apply to that employee.
The same values can be set over the API. Send only the fields you want to change — the response echoes what was saved:
curl -s -X PUT https://<your-agentwatch-host>/admin/alerts/rate-limit/limit \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"perUserRateLimit": 60, "perApiKeyRateLimit": 120, "globalRateLimit": 1000, "rateLimitMode": "enforce"}'rateLimitMode is enforce (429 over the limit) or monitor (allow, but
alarm — useful for a trusted enterprise gateway you do not want to throttle
while you find the right number). Hits appear under Rate-limit Alarms.
Repeated hits of the same limit by the same employee and key raise one alarm
per 5 minutes (AW_RATE_LIMIT_ALERT_THROTTLE_MS), so a client in a retry loop
does not flood the list or your inbox; changing the mode or the limit value
starts a new alarm straight away.
curl -s https://<your-agentwatch-host>/admin/alerts/rate-limit \ -H "Authorization: Bearer <tenant-admin JWT>"Testing a limit. A changed limit takes effect within 30 seconds (settings are cached). Test with an employee’s API key — requests made with a super-admin key are never rate-limited. Each request counts once, whichever endpoint it calls. A limit of N per minute refills continuously (one request every 60/N seconds), so after a 429 the next request is allowed as soon as one has refilled, not only at the top of the minute.
Which limit did a request hit?
Section titled “Which limit did a request hit?”| Response | Meaning | Where to look |
|---|---|---|
402 BUDGET_EXCEEDED |
An employee budget | Budget Management → that employee |
402 BUDGET_EXCEEDED, message names a team |
A team budget (enforcement is on) | Teams → that team’s limits |
402 with several details.blockers |
More than one limit refuses it — the message names the first, the list names all | Each entry’s scope (employee/team), period and limitType |
402 PLAN_LIMIT_EXCEEDED |
The organization’s plan call limit (refused requests do not count toward it from 2.0.363) | Platform Admin → Plans |
429 |
A rate limit | Rate-limit Alarms names the tenant and key |
429 with Retry-After |
The provider’s rate limit, passed through | Provider key pool — add keys or lower concurrency |
Budget and plan rejections are written to the usage table as blocked rows
with zero tokens and a blockCode (budget_exceeded, plan_limit_exceeded),
so they appear in reporting beside guardrail blocks rather than vanishing.
Rate-limit rejections are recorded the same way — blockCode: rate_limit_exceeded — when you enable “Record rate-limited (429) requests in
the usage table” under Settings → Rate Limits. That one is off by default
because a retrying client can generate thousands of 429s a minute, and each one
would be a row.
curl -s -X POST "https://<your-agentwatch-host>/admin/company/settings?organizationId=<org id>" \ -H "Authorization: Bearer <tenant-admin JWT>" -H 'Content-Type: application/json' \ -d '{"rateLimitRecordBlockedUsage": true}'I changed a limit and the next call still went through. Why?
Section titled “I changed a limit and the next call still went through. Why?”Before 2.0.295, up to half a minute. The new limit reached the database immediately, but enforcement kept reading the previous one from a 30-second authentication cache that the write did not clear — so a lowered limit did not bind until that cache expired.
From 2.0.295 a limit change takes effect on the next request. If you are on an earlier version and need a limit to bind at once, wait 30 seconds before testing, or restart the gateway.
This applies to an employee’s daily and monthly token limits, their rate limit, and team limits saved from the Teams dialog.
Sources and references
Section titled “Sources and references”- Where the gateway enforces these controls: LLM gateway and routing.
- Reporting and analytics: Observability and insights.