Spend refusals
For: Developers handling budget and quota refusals.
A budget, quota or job-tree limit refusal adds an oz0 object beside the error's type and message. Use the object's fields to decide whether to wait, request more budget or stop. The message is for people and its wording can change.
{
"type": "error",
"error": {
"type": "oz0_quota_exceeded",
"message": "team ml has spent $41.2000 of its $40.00 quota for October 2026; ask for more ...",
"oz0": {
"v": 1,
"scope": "team",
"scope_id": "team:ml",
"window": "month",
"resets_at": "2026-11-01T00:00:00Z",
"limit_usd": 40,
"spent_usd": 41.2,
"call_estimate_usd": 0.0912,
"limit_name": "monthly_usd",
"can_wait": true,
"raise": {
"by": ["team_admin", "tenant_admin"],
"command": "oz0 quota request team:ml --usd 10 --tenant acme --reason '...'"
}
}
}
}
A client branches on the fields: it waits until resets_at (or for Retry-After), follows raise.request once a budget request is open, or stops.
Which refusals carry it
| Error type | Status | What |
|---|---|---|
oz0_budget_exceeded | 402 | A job tree's budget or the tenant's monthly budget is spent |
oz0_quota_exceeded | 402 at the gateway, resource_exhausted (429) at the runtime API | A person's, a key's, a service's or a team's quota: money a month, jobs a day, or jobs at once |
oz0_budget_wait | 402 | A job tree's budget is spent and the job waits for a person to raise it (on_budget: wait) |
oz0_budget_forecast | 402 | A forecast asks a person before the tree reaches its budget |
oz0_tree_limit | 402 | A job tree's limit that is not money: model calls, tokens, children, children at once, depth, or an agent's delegation limits |
oz0_budget_busy | 429 | The call does not fit only because other calls of the same budget still run; send it again when they settle |
oz0_budget_unbounded (400) carries no object either: under reserve: strict the call's worst case cannot be bound, or under any mode it comes to more than a reservation can hold. No budget's figures are involved, and sending it again changes nothing.
oz0_budget_unavailable (503) carries no object: the budgets could not be read, which is an outage, not a refusal, and the call is sent again shortly. Neither does oz0_pool_cooling (429), a provider's cooldown, or oz0_killed.
Where it appears
| Surface | Where the object is |
|---|---|
The gateway (/llm/...), a node's local model call and a child job's admission | error.oz0 of the answer's body |
The runtime API's StartJob | A oz0.runtime.v1.SpendRefusal detail of the resource_exhausted error, with the object's fields |
GetJob and WatchJob | Job.refusal of a job a budget, quota or limit ended, and BudgetWait.refusal of a job that waits for a budget decision |
| A flow's webhook | The 429's body, error.oz0 |
| The MCP server | _meta["ai.oz0/refusal"].oz0 of the isError tool result, and structuredContent.error.oz0 too when the tool declares no outputSchema |
| A tool's MCP sampling request | error.data["ai.oz0/refusal"].oz0 of the JSON-RPC error the tool's server gets (code -1); on MCP's newer protocol, where the refused request ends the tool call, the agent's tool error carries the same object as structuredContent.error.oz0 |
| A2A | An item {"@type": "type.googleapis.com/google.protobuf.Struct", "value": {"error": {...}}} of the JSON-RPC error's data, and a data part {"error": {...}} of a failed or budget-waiting task's status message |
| AG-UI | RUN_ERROR: code is the error type and rawEvent.oz0 the object |
| The audit trail | refusal_detail of each run.refused entry, beside the clipped reason |
| The evidence record of a model call | refusal_detail, without the name of a person or team it is about |
A remote agent's session learns it from the answer to its usage reports, in the Oz0-Over-Budget-Detail header beside Oz0-Over-Budget.
Fields
| Field | Type | Meaning | Absent when |
|---|---|---|---|
v | number | The object's version: 1 | Never |
scope | string | What was spent or reached: tree, tenant, person (a person's account, an API key, a service identity or an operator certificate), team, or limit (a tree's limit that is not money) | Never |
scope_id | string | The tree's root job (tree, limit), or the principal or team (team:<name>) whose quota it is | For tenant, and when withheld |
window | string | The period the scope counts in: job (the tree, which never resets), month (the calendar month in UTC), day (the UTC day) or at_once (jobs running at the same time) | Never |
resets_at | string or null | When the window starts again, in RFC 3339 | Always present; null for a tree, a limit and jobs at once |
limit_usd | number | What the scope may spend, in US dollars | For limits and quotas on jobs, and when withheld |
spent_usd | number | What the scope has spent | As limit_usd |
reserved_usd | number | What calls of the scope still running hold at their worst case | When nothing is held, and when withheld |
call_estimate_usd | number | The refused call's worst case, at which it was to be reserved | When the refusal is not about a call (a start, a child's admission), and when withheld |
limit_name | string | The limit reached: model_calls, tokens, children, fan_out, depth, wait, delegation_max_depth, delegation_max_children, jobs_per_day, jobs_at_once or monthly_usd | For budgets, and when withheld |
steps | number | A count the tree reached: model calls, child jobs, children at once or depth | Unless limit_name is one of those |
steps_limit | number | The limit of steps | As steps |
tokens | number | The tree's tokens | Unless limit_name is tokens |
tokens_limit | number | The tree's limit on tokens | As tokens |
seconds | number | The time the tree waited for people | Unless limit_name is wait |
seconds_limit | number | The tree's limit on waiting | As seconds |
jobs | number | The jobs started today, or running now | Unless limit_name is jobs_per_day or jobs_at_once, and when withheld |
jobs_limit | number | The quota on those jobs | As jobs |
calls_running | number | For oz0_budget_busy: the calls of the budget still running | For every other type, and when withheld |
forecast_chance | number | For oz0_budget_forecast: the share of the agent's earlier trees that went on to pass the budget from here | For every other type |
can_wait | boolean | true when the job waits for a person's decision instead of failing: a tree under on_budget: wait, a forecast, a quota refusing a job's model call | Never |
raise | object | Who can raise the scope, and how | Never |
raise.by | array of strings | The roles that can: tree_owner (the person who started the tree), team_admin, tenant_admin | Never (may be empty) |
raise.request | object | The budget request opened about the refusal | Until one is opened: the gateway refuses before any request exists, so its answer never has one; a job's wait and its failure name it |
raise.request.id | string | The request's ID, for oz0 quota approve | As raise.request |
raise.request.url | string | The request on the web UI's Approvals page, which opens with that request marked (decided ones shown too when it is no longer pending) | Without a public URL (--public-url) |
raise.command | string | The CLI command that asks for more (with the tenant it is in), sets more, or approves the request | For an agent's own delegation limits, and when withheld |
withheld | boolean | true when the scope's name and figures were left out for this caller | When nothing was left out |
Additions are fine: a client ignores fields it does not know. A renamed field, or one whose meaning changes, needs a new version, v: 2.
Who sees the numbers
A person's or a team's figures and name are shown only to a caller who may read that quota today: the person themselves, for their own quota, or someone whose role holds usage.read and whom works-council mode lets see that figure (a team of at least the tenant's threshold of people, a service identity, or anyone once an admin turned the mode off).
For anyone else the object keeps v, scope, window, resets_at, can_wait and raise without its command, sets withheld, and the message names neither the quota nor its figures:
{"v": 1, "scope": "team", "window": "month", "resets_at": "2026-11-01T00:00:00Z", "can_wait": true,
"raise": {"by": ["team_admin", "tenant_admin"]}, "withheld": true}
Each surface filters for its own caller. The gateway answers the node that acts for a job, so its answer is filtered for the person who started the job's tree; the runtime API, MCP, A2A and AG-UI filter for whoever called them; a webhook's answer for the webhook's own identity (service:webhook/<flow>). A job tree's own budget, the tenant's budget and the tree's limits are not figures about people, and are shown to everyone who gets the refusal.
The audit trail's run.refused entry keeps the refusal as the limit wrote it, whoever was refused: its reason and refusal_detail name the quota that stopped the start or the call, with its figures, because the trail is for those who hold audit.read. The evidence record of a model call keeps the figures but never the name of a person or team: scope_id and raise.command are left out for person and team, as the record names only kinds of budget.
Retry-After
A refusal of a quota or of the tenant's month whose resets_at is within 24 hours carries Retry-After, in seconds until the reset: on the gateway's 402, on a webhook's 429, and in the metadata of StartJob's error. A client that honours it waits without code of its own. A tree's budget and its limits never reset, so their refusals never carry it. oz0_budget_busy keeps its own short Retry-After (two seconds), since what it waits for is calls that settle, not a reset.