Approval is the easy part · MIT
Your agent got approval at 2:14.
It executed at 2:31 with a dead token.
Pausing for approval is the easy part. Tokens expire, arguments drift, and permissions change while you wait. nominee checks all three the moment the call actually runs, and writes a receipt.
token minted at execution · bound to the args a human saw · spendable once
No signup · no API key · runs offline
the proof: nominee 8/8, naive refresh 1/8$ agent run --task "refund ord_42"
→ refund.issue({ orderId: 'ord_42', amount: 200 })
⏸ ask rule: approval requested
→ request returns — ActionPendingError (2:14)
… Slack card to alice · access-token TTL: 10 min
⏳ the gap — 17 minutes with nothing happening
✗ token expired during the pause (2:31)
✓ alice approves out of band — Slack (2:29)
→ resumeAction() → single-use capability issued
✓ fresh token minted at execution — not the dead one
✗ mutated args bounce — AuthorizationInputChangedError
✓ executed once · receipt sealed
The lead proof
Naive refresh fails 7/8. nominee gets 8/8. Same agent code.
A mock OAuth server with rotating refresh tokens and real latency. The agent pauses for approval, then fires eight tool calls at once. Two common implementations (A and D) break; nominee (B and C) does not.
A) naive — hold the access token across the pause
resource → 401 token_expired
B) nominee — fresh token at call time
✓ before → 200 OK | after pause → 200 OK
C) nominee + 8 concurrent calls
✓ network refreshes = 1 (single-flight) | resource 200s = 8/8
D) refresh WITHOUT single-flight (8 concurrent)
✗ network refreshes = 8 | invalid_grant failures = 7/8
Secondary proof: an approved $300 refund is replayed as $999,999. The mutated
arguments bounce with AuthorizationInputChangedError; the attempt
stays on the receipt chain. Try it: npx nominee-cli.
What breaks after "approve"
The pause is the easy part. The moment after is not.
Once a human approves out of band (Slack, email, push), four things can change before the tool runs. nominee covers each one.
refund.issue
Refund $200 for order ord_42
Token expired
The credential minted before the pause died while a human deliberated. nominee
resolves tokens at execution, inside the run() callback.
A token is never older than the call it serves.
Arguments drifted
The agent reworded the input while you approved. Every approval is bound to a
canonical hash of the exact input, so drifted arguments throw
AuthorizationInputChangedError instead of executing.
Permission changed
The user lost access while you deliberated. When an action names a resource, the authorizer is consulted while planning and again after the capability is consumed. A permission revoked mid-approval fails closed.
Approval replayed
The approved call runs twice. The capability is single-use and expires;
resumeAction() rotates it and invalidates the old value. One approval,
one execution.
Add it to your agent
Wrap the tools you already have.
Write the rules, pass your existing tool functions to guard(), and keep the rest of your agent code.
import { Nominee, allow, deny, ask } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [
allow('orders.read'),
allow('refund.issue', { when: ({ input }) => input.amount <= 50 }),
ask('refund.issue', { when: ({ input }) => input.amount <= 500 }),
deny('refund.issue'),
deny('customers.export'),
],
fallback: 'deny',
},
})
const tools = nominee.guard({
'orders.read': readOrder,
'refund.issue': issueRefund,
'customers.export': exportCustomers,
}, { user: session.userId })
import { guardTools } from 'nominee-ai'
const result = await generateText({
model,
tools: guardTools(nominee, { readOrder, issueRefund }, {
user: session.userId,
}),
})
// per-tool config (connection tokens, approval: true) → nomineeTool()
import { Nominee } from 'nominee'
import { Auth0 } from 'nominee-auth0'
// same API — swap the strategy for managed Token Vault + phone approval
const nominee = new Nominee({
strategy: Auth0({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
subjectToken: ({ user }) => store.getRefreshToken(user),
ciba: { bindingMessage: (req) => `Approve: ${req.action}` }, // phone approval
}),
})
If approval comes back in Slack, email, or a push after this request ends, the call
throws ActionPendingError with a durable action id. Persist it and resume
later.
When the approval outlives the request →
run({ user, tenant, action, resource, input }, execute)execute(input)Works with the stack you already run
- Vercel AI SDK & Eve
- Cloudflare Agents & Workers
- Auth0 Token Vault & CIBA
- Supabase Token store
No rules yet?
Discover what your agent already does.
Two lines wrap your tools in report-only mode. nominee records which callbacks start, their argument shapes and ranges, and where the agent's authority is unbounded. The report below comes from a hard-coded demo agent. Run it on your agent to see yours.
const tools = nominee.observe(yourTools) // report-only — deny/ask gates stay off
! ENFORCEMENT WAS OFF: callbacks reached the tools.
refund.issue 5 calls · mutate
↳ amount: number, observed 5–2000 [unbounded]
orders.read 3 calls · read
customers.export 1 call · unknown
The same report feeds npx nominee-cli generate for an editable, evidence-backed
starter policy. Observed thresholds are not security recommendations.
Supporting proof · prompt injection
The model was hijacked. The tool still did not run.
The refusal is on the record: sealed into the hash-chained receipt log, tamper-evident against a downstream log editor (anyone with the key can verify; that is not a legal signature). This is blast-radius containment, not detection.
-
✗
Denied before the tool runs
denythrowsPolicyDeniedErrorbefore your tool function receives control. The hijacked model cannot talk its way past it. -
§
Refused on the record
Refusals are receipted as faithfully as approvals. When nominee says no, a hash-chained record shows the refusal and the reason.
-
⏸
Escalation still goes to a human
A human denies the delete-the-evidence step through the same out-of-band approval path. The denial enters the same receipt chain.
Honest about scope
Does your approval return in the same request? Then you don't need nominee.
If the answer returns in the same HTTP request, state has no time to go stale. You also do not need nominee when:
- Your approval comes back in the same request that asked for it.
- Your agent only reads public or low-risk data.
- Your framework already enforces every permission your application needs.
- A few local if-statements cover your tools and you do not need durable approvals or shared limits.
Use nominee when a human approves out of band (Slack / email / push), the agent takes a write action on a third-party API, and you want the moment after "approve" correct and on the record.
MCP
OAuth authorizes the connection. nominee authorizes the action.
MCP OAuth decides which client may talk to which server. It does not decide
whether email.forward may run with these arguments for this user.
nominee-mcp wraps the handler so a hijacked model cannot
execute the denied tool.
FAQ
The questions people actually ask.
How do I restrict what my AI agent can do?
Write ordered allow, ask, and deny rules,
then wrap your tools with nominee.guard() or a framework adapter.
Nominee checks each call before the tool function runs.
How do I require human approval before an agent issues a refund?
Add ask('refund.issue') or another ask rule. The call
pauses for your approval UI, and the approval applies only to the arguments the
person reviewed.
What happens to the token while an approval is pending?
No token waits with it. Nominee resolves credentials inside the
run() callback, at execution time, after the capability is consumed.
A token never goes stale across the pause because it is minted after the pause,
not before it.
How do I add permissions to AI tool calls?
Keep your agent framework and tools. Put Nominee around the tools that can change data, spend money, or send messages; its rules decide which calls run.
Isn't this just an if-statement with extra steps?
For one low-risk tool, an if-statement may be enough. Nominee is useful when approvals outlive a request, workers share limits, permissions can change during a pause, or several frameworks need the same rules and receipts.
Can the model talk its way past a deny?
Not if the call routes through Nominee: a deny throws
PolicyDeniedError before your tool code runs. Keep raw tools and
credentials outside model-controlled code so the wrapper cannot be bypassed.
Do receipts store my data?
Not by default. Tool inputs are recorded as inputHash, a SHA-256 of
the canonical JSON, so you can prove what an approver saw without writing user
data into the log. Pass receipts: { input: 'raw' } for the
full input instead, or 'none' to skip it entirely.
Can receipts be forged?
Tamper-evident, not non-repudiation. The chain is hash-chained (HMAC): anyone with the key can verify nothing was edited, but anyone with write access to the whole log could also rewrite it consistently. The guarantee is evidence against a downstream log editor, not a legal signature.
How is this different from Arcade, Composio, or Vercel Connect?
Those products can manage connections and tools. nominee is the open-source enforcement layer that binds your application policy to the exact tool action. It can use a managed platform, your IAM, or your own token store underneath.
Does nominee replace my auth provider (Auth0, Clerk, WorkOS)?
No. Authentication (who is the user) is a separate, solved problem. nominee is the layer above it, deciding what an already-authenticated agent may do as that user. Auth0 is one optional strategy among several, not a requirement.
What happens if I don't configure a policy?
Everything is allowed, and still receipted. nominee doesn't restrict anything until you add rules, so you can adopt it incrementally: wrap your tools first, watch the receipt chain, then tighten the policy once you know what your agent actually does.
When don't I need nominee?
Does your approval come back in the same HTTP request that asked for it? Then you don't need nominee. A read-only agent has no authority worth guarding. A platform whose native permission system covers you end-to-end already handles this. If one managed vendor covers tools, auth, and policy, use Arcade or Composio directly.
Packages
One authorization layer, wherever your agent runs.
nominee-mcp
Register guarded tools on the official MCP server SDK. OAuth connects; nominee decides which call runs.
nominee-cli
Run the offline refund + replay-proof demo with no clone, build, or API key. Also verify receipts and check policy files.
nominee-ai
guardTools wraps your AI SDK tools in one line; nomineeTool for per-tool config. Runs on Cloudflare Agents.
nominee-supabase
Store provider tokens in Supabase; nominee reads and refreshes them. Zero deps.
nominee-auth0
Auth0 Token Vault for federated tokens, and CIBA approvals on the user's phone.
nominee-postgres
Transactional actions, budgets, single-use capabilities, outcomes, journals, and receipt streams.
nominee-openai
Decision-bound tools with Nominee ask rules mapped to native resumable approvals.
nominee-mastra
Mastra tools with native or portable durable approval and execution-time credentials.
nominee-langchain
LangChain structured tools whose side effects run through nominee.run().
Make the moment after "approve" correct.
Run the 7/8 → 8/8 proof, then wrap one of your own tools. Same agent code, no signup, no API key.