Budgets
A budget is a per-agent policy: caps on dollars, tokens and runs per day, plus per-run and per-tool limits. With hardFail: true, a call that would start while a limit is already reached is refused before the model is called: HTTP 402 on the REST paths, a failed step inside workflows. With false, the breach is logged and the call proceeds. If the budget cannot be read, hard-fail budgets fail closed.
Set a budget
Section titled “Set a budget”PUT /v1/agents/{name}/budgetContent-Type: application/json
{ "maxCostUsdPerDay": 5.00, "maxCostUsdPerRun": 1.00, "maxTokensPerDay": 500000, "maxRunsPerDay": 100, "toolLimits": { "web_search": 50, "slack.post_message": 100 }, "hardFail": true, "notifyAtPct": 80}| Field | Meaning |
|---|---|
maxCostUsdPerDay |
Max USD across all runs in one budget day. Blocks once the day’s recorded spend exceeds it |
maxCostUsdPerRun |
Max USD for one run. Enforced mid-run for workflow runs only (below). Elsewhere it is checked only by the opt-in forecast and reported after the fact as an anomaly_detected event |
maxTokensPerDay |
Max tokens (in + out) per day. Blocks once the day’s recorded tokens reach it |
maxRunsPerDay |
Max runs per day. A workflow run counts once, when it succeeds, like a plain run. Failed runs are not counted; spend inside a run does not add to the count |
toolLimits |
Per-tool call counts per day. Keys are a tool name (web_search) or connector.action for connector nodes. Enforced for workflow tool and connector nodes only |
hardFail |
true: a reached limit blocks. false: logged, call proceeds |
notifyAtPct |
Stored (default 80 when omitted or outside 1-100). No alert is sent on it today |
Day boundary. The daily rollup resets at local midnight using PAL_DEFAULT_TIMEZONE (an IANA zone; UTC when unset). A schedule’s own timezone affects only when its cron fires, not the budget day.
Read and remove:
GET /v1/agents/{name}/budget # 404 {"error":"no budget configured"} when noneGET /v1/budgets # bare array of per-agent budgetsDELETE /v1/agents/{name}/budgetTenant-wide cap
Section titled “Tenant-wide cap”One daily cap across all agents, including agentless /v1/completions calls: PUT, GET and DELETE on /v1/budgets/tenant with { "maxCostUsdPerDay": 50, "hardFail": true } (must be greater than 0). It is stored under the reserved agent name *, hidden from GET /v1/budgets, and the per-agent routes reject * as a name. A soft tenant cap never masks an agent’s own hard-fail limit.
What is enforced where
Section titled “What is enforced where”Each check runs before the model or tool call. A hard-fail budget blocks; a soft budget logs and proceeds.
| Path | Cost/day | Tokens/day | Runs/day | Tenant cap | Per-run | Per-tool |
|---|---|---|---|---|---|---|
Run create, session message, /v1/completions with agentName |
Yes | Yes | Yes | Yes | No | No |
/v1/completions without agentName |
n/a | n/a | n/a | Yes | n/a | n/a |
Workflow ai-step and confidence votes |
Yes | Yes | No (run already admitted) | Yes | Yes (spend so far) | n/a |
Workflow tool, web_search |
Yes | Yes | No | Yes | Yes (spend so far) | Yes |
Workflow tool, sandbox tools |
No | No | No | No | No | Yes |
Workflow connector |
No | No | No | No | No | Yes (connector.action) |
| Voice (Twilio inbound, LiveKit token) | Yes | Yes | Yes | Yes | Flat estimate | No |
web_searchinside a session turn is covered by the check at the start of the turn; its spend is recorded after.- Voice reserves a flat estimate on connect and reconciles it to the real call duration on the provider’s status callback.
- Marketplace invokes check the seller agent’s budget and return 402 with
seller agent budget limit reached: <reason>.
Per-run cap inside a workflow
Section titled “Per-run cap inside a workflow”Workflow runs record each model call’s tokens and cost onto the run as they happen. Before every ai-step, confidence vote and web_search node, the interpreter re-checks the tenant cap, the agent’s daily caps and maxCostUsdPerRun against this run’s spend so far. Hard-fail stops the run at that step with no further provider spend.
Per-tool limits in workflows
Section titled “Per-tool limits in workflows”toolLimits is checked before a workflow tool or connector node dispatches, and one use is counted after dispatch. A tool that was never dispatched (runtime not wired) is not counted. See Agents.
What a blocked call looks like
Section titled “What a blocked call looks like”POST /v1/runs with a reached hard-fail budget. The reason names the limit that tripped:
HTTP 402{ "error": "agent budget limit reached: daily run limit reached (100/100)", "reason": "daily run limit reached (100/100)"}Other reason strings: daily spend would reach $5.0123 exceeding limit $5.0000, daily token limit reached (500000/500000), tenant-wide daily spend would reach $50.0100 exceeding tenant limit $50.0000, run spend $1.0042 reached per-run limit $1.0000, tool "web_search" budget exhausted (50/50 today).
| Caller | What it sees |
|---|---|
POST /v1/completions |
402 { "error": "budget limit exceeded", "reason": "<reason>" } |
| Session message | No HTTP error. The session replies sorry — this agent has reached its usage limit for today and goes idle |
| Workflow step | Fails with budget limit exceeded: <reason> |
| Voice | Twilio <Reject reason="busy"/>; other providers 402 agent voice budget exhausted; the LiveKit token route 402 { "error": "agent voice budget exhausted: <reason>" } |
When the budget cannot be read
Section titled “When the budget cannot be read”A database error while reading a budget is not “no budget configured” (that allows the call). The check fails closed when the budget is hard-fail, or when the row itself could not be read (its policy is unknown, so it is treated as hard-fail). The error text is logged, never returned.
- Run create answers
503with{ "error": "budget check unavailable; retry shortly", "reason": "budget check unavailable" }. Only run create returns 503. /v1/completionsanswers 402 with reasonbudget check unavailable. Sessions replysorry — I can't check this agent's usage limit right now, try again shortly. Workflow steps fail withbudget limit exceeded: budget check unavailable. Voice declines as for a budget block.- Soft budgets: if the row was read and says
hardFail: falsebut the usage lookup then errors, the call proceeds.
Try it
Section titled “Try it”TOKEN=$(curl -s localhost:8080/auth/login -H 'Content-Type: application/json' \ -d '{"email":"admin@pal.local","password":"pal"}' | jq -r .token) # dev seed user
# Cap an agent at 1 run per day, hard-failcurl -s -X PUT localhost:8080/v1/agents/demo/budget \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"maxRunsPerDay":1,"hardFail":true}'
# Create two runs; the second returns HTTP 402 once the first is recordedcurl -s -i -X POST localhost:8080/v1/runs \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -d '{"agentName":"demo","input":{"q":"hi"}}'Usage is recorded when a run finishes, so create the second run after the first completes.
Pre-run cost forecast
Section titled “Pre-run cost forecast”POST /v1/runs/forecast estimates tokens and cost from the agent’s succeeded runs over the last 30 days (average tokens per run, plus about one token per four characters of input) and checks the estimate against the budget. It answers 402 instead of 200 when a hard-fail budget would be breached. Forecasting is opt-in: run creation does not call it.
- With history:
calibrated: true, and the per-run and daily cost caps are checked against the estimate. - With no succeeded runs: estimates are omitted,
calibratedis false,confidenceis 0, and onlymaxRunsPerDaycan block. An unknown cost never auto-blocks. confidencegrows with sample size:min(0.95, 0.3 + 0.03 × √runs).
POST /v1/runs/forecast{ "agentName": "research-agent", "input": "quantum computing" }
200 OK (values illustrative){ "agentName": "research-agent", "estimatedTokensIn": 800, "estimatedTokensOut": 2000, "estimatedCostUsd": 0.004, "confidence": 0.45, "calibrated": true, "wouldExceedBudget": false}When it would exceed (HTTP 402 if hardFail), wouldExceedBudget is true and blockReason explains, for example per-run cost $1.2000 exceeds limit $1.0000; several reasons join with ; . Others: daily cost would hit $5.0400 exceeding $5.0000, daily run limit reached (100/100). blockReason is omitted when nothing would block.
Not enforced yet
Section titled “Not enforced yet”- Per-run cap mid-run on single-call paths. A plain run, a session turn and a completion make one model call, so there is no point to stop at. Overshoot is reported after the run as an
anomaly_detectedevent, which blocks nothing. - Projected spend at run creation. Run create checks recorded spend, so the run that crosses a daily cap is allowed and the next is blocked. Use the forecast to estimate ahead.
- Failed runs toward runs/day. Only succeeded runs count. A failing agent never trips
maxRunsPerDay, though its spend still counts toward cost and token caps. - Per-tool limits outside workflows. Session tool loops and
POST /v1/connectors/{id}/executedo not consulttoolLimits. - Daily cost and token caps on sandbox tool and connector nodes. Those nodes check only
toolLimits; theirai-stepneighbours carry the model spend and are checked. - Reservation. Checks read recorded usage and spend is recorded after the call, so concurrent calls can together overshoot a cap.
notifyAtPctalerts. Stored; nothing fires on it.
Metrics
Section titled “Metrics”Each counter is emitted under its pal. name.
| Metric | Attributes | Meaning |
|---|---|---|
pal.run.budget.blocks |
pal.agent_name |
Runs refused by a hard-fail budget at POST /v1/runs (HTTP 402) |
pal.budget.check.errors |
pal.budget_check (tenant, agent, run, tool), pal.agent_name |
Budget lookups that errored and failed closed |