Approval is the easy part · MIT
Your agent got approval at 2:14.
It executed at 2:31 with a dead token.
Your framework can already pause a tool call — that part is solved. It cannot tell you the token expired during the pause, the arguments drifted, the permission changed, or the approval got spent twice. nominee makes the moment after "approve" correct, and leaves a receipt.
Does your approval come back in the same HTTP request that asked for it? Then you don't need nominee. Open-source TypeScript · no signup or API key.
$ 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. The natural-but-wrong first attempts (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, and 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 still go wrong before the tool runs. nominee was built for exactly these four.
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
}),
})
Approval comes back in Slack, email, or a push — not in this request? 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? (a section, not the lead)
Discover what your agent already does.
Two lines wrap your existing tools, report-only: it records which tool callbacks actually start, their argument shapes and numeric ranges, and where nothing bounds the agent's authority. The report below is a sample report from a hard-coded demo agent — run it on your own 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.
And 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. The half nobody else has: when nominee says no, there is a hash-chained record that it said no, and why.
-
⏸
Escalation still goes to a human
The delete-the-evidence step is escalated to, and denied by, a human — the same out-of-band approval path, sealed into the same chain.
Honest about scope
Does your approval return in the same request? Then you don't need nominee.
The self-selection test: if the human's answer comes back inside the HTTP request that asked for it, the pause never happened, and neither did the staleness. The full list, in the open, below the proof:
- Your approval comes back in the same request that asked for it — no pause, no staleness.
- 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) and the agent takes a write action on a third-party API — and you want that moment after "approve" to be 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 still 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?
Nothing — and that's the point. 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' } if you want the
full input on the record instead, or 'none' to skip it entirely.
Can receipts be forged?
Tamper-evident, not non-repudiation — say that plainly. 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. It composes with whatever you already use; 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 to see what your agent actually does, then tighten the policy once you know.
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. Also: a read-only agent with no authority worth guarding, your platform's native permission system covering you end-to-end, or one fully-managed vendor for 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 too.
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.