Guide ·
Spend caps for what an agent's tools charge, not only its tokens
Since 22 July 2026 the OpenAI API has hard spend limits for an organization and for each project: when tracked spend reaches a monthly cap, API requests return HTTP 429 (OpenAI API changelog, spend limits guide). OpenAI notes that enforcement is not instantaneous, so recorded spend can go slightly over the cap.
That limit counts what OpenAI bills. It does not count what an agent’s tools do on other services: a card charged through a payments tool, an email sent, a record written to a CRM, a call to a paid API. Those are tool calls, and they cost money or cannot be taken back whatever the model’s bill says.
okgate sits between the agent and its MCP servers and sees every tool call before it is sent. It counts each call against the caps in okgate.yaml, per run and per day. A call that would take a count over its cap is refused with CAP_EXCEEDED, and the server never receives it.
The caps init writes
npx -p @agentwares/agentguard okgate init writes these into okgate.yaml (from the run on 7 October 2026 with agentguard 0.1.6, okgate’s name until 8 October; the output below is that run’s, verbatim):
# Blast-radius caps. The call that would exceed a cap gets CAP_EXCEEDED with the remaining budget.
# `writes` counts every write or spend; `deletes`/`emails` count the subsets matched by `counters`.
caps:
per_run:
tool_calls: 400
writes: 50
deletes: 10
emails: 5
spend_usd: 25
per_day:
spend_usd: 200
# Dollar amounts. Declare how to read the amount from a spend tool's arguments; results reporting
# cost_usd / amount_usd are picked up automatically. Unknown spend tools cost `default_usd`.
spend:
tools: {}
# stripe_create_charge: { amount_arg: amount, divisor: 100, currency_arg: currency }
# openai_*: { fixed_usd: 0.01 }
default_usd: 0tool_callscounts every call.writescounts every call classified as a write or a spend: records created, updated or deleted, messages sent, charges.deletescounts writes the server marksdestructiveHintor whose name matches*delete*,*remove*,*destroy*,*drop*,*truncate*,*purge*,*wipe*and a few more.emailscounts writes whose name matches*email*,*_send_mail*,*sendmail*,*_mail_*,*message_send*or*send_message*.spend_usdcounts dollars, read as described below.- Any other name under
counters:is a counter of your own: map it to tool patterns (paid_search: ["serpapi_*"]) and cap it like the others. It counts every call whose name matches, read or write, which suits a paid API that charges per request.
per_run starts from zero for each run. A run is named by the X-Run-Id header over HTTP, else a runId in the call’s _meta, else the MCP session, else one id per proxy process. per_day adds up every run through the UTC day, in .okgate/state.json on the machine running the proxy.
Where the dollar amount comes from
- From the arguments, before the call. A rule under
spend.toolsnames the argument holding the amount (amount_arg, divided bydivisorfor cents) or a flatfixed_usdper call. The amount is known before the call is sent, so the call that would go over the cap is the one refused. - From the result, after the call. With no rule, a result field named
cost_usd,amount_usd,spend_usd,_meta.cost_usdorusage.cost_usdis counted once the call returns. The call that crosses the cap has already happened; the next spend call is refused. - Neither. A tool classified as a spend with no rule and no such field costs
default_usd, whichinitsets to 0. It is still checked against the cap, but counts nothing until you add a rule.
When a rule names a currency_arg and the call’s currency is not USD, USDC, USDT or DAI, the call counts $0 toward spend_usd; cap such a tool by count with a counter of its own.
Two runs against a $20 daily cap
The demo CRM that ships with okgate has a crm_charge_card tool that takes amount_cents. init classified it as a spend from the verb in its name; in a dry-run it counted $0 (see what a dry-run of an agent’s MCP writes shows). Three edits to the file init wrote: enforce mode, the daily cap lowered to $20 for the demo, and one rule saying where the amount is.
mode: enforce
caps:
per_run:
tool_calls: 400
writes: 50
deletes: 10
emails: 5
spend_usd: 25
per_day:
spend_usd: 20
spend:
tools:
crm_charge_card: { amount_arg: amount_cents, divisor: 100, currency_arg: currency }The scripted agent then ran twice against the fake CRM. In the first run its $12 charge went through:
run_155 tool calls between 2026-10-07T05:25:36.435Z and 2026-10-07T05:25:36.573Z across crm.
50 writes, 1 send, $12.00 spent.
| # | when | code | tool | why |
|---|---|---|---|---|
| 5 | 2026-10-07T05:25:36.468Z | APPROVAL_REQUIRED | crm_delete_contact | "crm_delete_contact" needs a human's approval before it runs (approval apr_a6afd3a6bf) |
| 10 | 2026-10-07T05:25:36.487Z | LOOP_DETECTED | crm_update_contact | "crm_update_contact" was called 3 times with the same arguments in the last 30 calls |
| 55 | 2026-10-07T05:25:36.573Z | CAP_EXCEEDED | crm_create_contact | writes cap for this run is 50; used 50, this call would make it 51 |
By class: write 52, read 2, spend 1By outcome: ok 52, pending 1, halted 1, blocked 1
| tool | class | calls |
|---|---|---|
crm_create_contact | write | 46 |
crm_update_contact | write | 4 |
crm_list_contacts | read | 1 |
crm_get_contact | read | 1 |
crm_delete_contact | write | 1 |
crm_send_email | write | 1 |
crm_charge_card | spend | 1 |
Audit chain: 111 entries, verified (~/demo-spend/.agentguard/audit.jsonl).Other runs: agentguard report --all
In the second run the same charge would have taken the day to $24. It was refused before it reached the CRM, and the rest of the run carried on under its other limits:
run_256 tool calls between 2026-10-07T05:25:39.034Z and 2026-10-07T05:25:39.158Z across crm.
50 writes, 1 send.
| # | when | code | tool | why |
|---|---|---|---|---|
| 60 | 2026-10-07T05:25:39.067Z | APPROVAL_REQUIRED | crm_delete_contact | "crm_delete_contact" needs a human's approval before it runs (approval apr_a6afd3a6bf) |
| 62 | 2026-10-07T05:25:39.072Z | CAP_EXCEEDED | crm_charge_card | spend_usd cap for today is $20; used $12, this call would make it $24 |
| 65 | 2026-10-07T05:25:39.080Z | LOOP_DETECTED | crm_update_contact | "crm_update_contact" was called 3 times with the same arguments in the last 30 calls |
| 111 | 2026-10-07T05:25:39.158Z | CAP_EXCEEDED | crm_create_contact | writes cap for this run is 50; used 50, this call would make it 51 |
By class: write 53, read 2, spend 1By outcome: ok 52, blocked 2, pending 1, halted 1
| tool | class | calls |
|---|---|---|
crm_create_contact | write | 47 |
crm_update_contact | write | 4 |
crm_list_contacts | read | 1 |
crm_get_contact | read | 1 |
crm_delete_contact | write | 1 |
crm_send_email | write | 1 |
crm_charge_card | spend | 1 |
Audit chain: 111 entries, verified (~/demo-spend/.agentguard/audit.jsonl).Other runs: agentguard report --all
This is the tool result the agent got back for the charge, as the audit log has it:
{
"code": "CAP_EXCEEDED",
"cause": "spend_usd cap for today is $20; used $12, this call would make it $24",
"fix": "stop for today and report to the user; a human can raise caps.per_day in agentguard.yaml",
"retryable": false,
"details": {
"scope": "per_day",
"counter": "spend_usd",
"limit": 20,
"used": 12,
"attempted": 24,
"remaining": {
"tool_calls": {
"per_run": 395
},
"writes": {
"per_run": 47
},
"deletes": {
"per_run": 10
},
"emails": {
"per_run": 4
},
"spend_usd": {
"per_run": 25,
"per_day": 8
}
},
"runId": "run_2"
}
}And the counters afterwards:
$ agentguard status --run run_2
policy ~/demo-spend/agentguard.yaml (mode: enforce, 1 upstreams)
kill off
http not running (agentguard proxy --http)
audit 111 entries in ~/demo-spend/.agentguard/audit.jsonl
pending 1 approval — agentguard approvals
run run_2
per_run tool_calls 52/400 writes 50/50 deletes 0/10 emails 1/5 spend_usd 0/25
per_day spend_usd 12/20The other refusals in those reports come from the same file: the per-run cap of 50 writes stopped each run’s burst of creates, the loop breaker stopped a repeated update, and in enforce mode the delete returned APPROVAL_REQUIRED, which a person clears with okgate approve <id>.
What these caps do not cover
- Token spend inside a model call is not an MCP tool call, so the proxy does not see it. okgate’s SDK has a guarded
fetchthat reads token usage from OpenAI, Anthropic and Gemini responses and counts it against the samespend_usdcaps. - Per-day counts are kept per machine. Two machines running the proxy each get their own day.
- A charge priced only by its result is counted after it happens, so one call can take the total past the cap.
Try it
In the project whose MCP config your agent uses:
$ npx -p @agentwares/agentguard okgate init