If the search was “agent budget limits per user,” you want this: after this principal has been allowed to attempt a class of tools N times, the next match does not run until a human says so (or you deny it). Count in your process, keyed by user (and tenant, if you have one), and reserve the slot atomically before execution. An allowed call whose tool later throws still consumed an attempt; that fail-closed accounting prevents concurrent calls from both spending the last slot. Provider rate limits, output-token caps, and “please only search twice” in the prompt are not that counter.
The naive approach (and when it is sufficient)
const used = new Map() // JSON tuple key → number
async function search(tenant, user, query) {
const key = JSON.stringify([tenant, user, 'search'])
const n = used.get(key) ?? 0
if (n >= 20) throw new Error('search budget exhausted')
used.set(key, n + 1)
return index.query(query)
}
That Map is enough when you have one Node process, one tool class, you are fine hard-failing at N, and a restart that zeros the counter is acceptable (dev, single replica, budget as a courtesy). If you are protecting the total bill, pair it with cumulative usage accounting or a provider spend quota; an RPM cap only slows the rate at which cost accumulates.
A per-request output-token cap is one useful component when generated-output cost is the risk. It does not cap input tokens, repeated model requests, or tool/API spend, so an aggregate cost limit still needs cumulative usage accounting or a provider spend quota. That is separate again from “this tenant and user can hit this policy rule 10,000 times or refund in a loop.” Keep the controls you need; do not pretend they substitute.
Where the Map lies
- Async or threaded concurrency. The shown read/check/set is synchronous and does not race on one Node event loop. If a real reservation crosses an
awaitor runs in worker threads, two calls can both readn = 19; use a mutex or atomic store. - Replicas. Each instance has its own Map. Production needs a shared counter keyed by tenant+user+rule.
- Hard fail vs escalate. Throwing looks like an error to the model, which may retry. Human approval breaks that loop only when the integration resumes the same pending action: handle
ActionPendingErrorby persisting its action ID and using the resume path, or use a native resumable approval integration. Treating pending as an ordinary error can create another pending action on retry. - Count denies and failures? A policy denial should not eat the budget. Reserve a slot atomically once the call is allowed and execution is about to be attempted; nominee deliberately does not release that reservation if the tool later throws or a post-consumption authorization recheck fails.
When nominee is a fit (and when it is not)
allow('search.*', { maxCalls: 20 }) is that counter: call 21 for the same policy version, rule position, tenant, and user escalates to ask (receipt escalated: 'budget'), it does not silently deny. Agents that share those fields and an action store share the budget; give independently budgeted agents distinct policyVersion namespaces. Under production: true the reservation lives on the action store so two replicas cannot both spend the last slot.
allow('orders.read')
allow('search.*', { maxCalls: 20 })
ask('refund.issue', { when: ({ input }) => input.amount <= 500 })
deny('refund.issue')
Skip nominee when a platform quota already stops the dangerous calls, or when the agent only reads public, non-sensitive data through APIs that already enforce suitable quotas. “Read-only” alone is not safe: private-data reads can still exfiltrate information and costly reads can still exhaust a budget. If you only need to cap aggregate LLM spend, use cumulative usage accounting or a provider spend quota. OpenFGA should answer live resource entitlement through nominee's authorizer while the action names a resource; that path is rechecked after an approval pause. Keep when predicates for argument matching and a local maxCalls for loop containment. Deeper: Native approval vs action authorization.
See it for yourself
Allow, ask, deny — then the chain verifies.