Documentation
The authorization layer for AI agents. Your agent logs in as you — nominee decides what it can do as you. Policy, approvals, and receipts on every agent tool call — dependency-free, framework-neutral, no SaaS.
What nominee is
nominee sits in-process between the model and your tools. Every tool call is
checked against a declarative allow / deny /
ask policy before it runs (nominee.authorize(), or
nominee.guard() to wrap a whole tools object in one line), risky
calls pause for a human, and every decision — including refusals — is sealed
into a hash-chained, tamper-evident receipt log. Tools that need third-party
credentials get a fresh token at the instant they act, via a pluggable
strategy — freshness, refresh-token rotation, and single-flight are
handled for you.
authorize({ tool, input, user })execute(input)The core is dependency-free. Think of it as the Passport.js of agent authorization — scoped to the multi-framework, no-lock-in, standalone tail, where you want this behaviour without buying into one vendor's runtime.
When you don't need nominee
nominee is a focused utility, not a platform. Skip it when something you already have does the job:
- A read-only agent with no authority worth guarding. Nothing to deny; nothing to receipt.
- Your platform's native permission system covers you end-to-end and you're happy inside it.
- You want one fully-managed vendor for tools + auth + policy. Use Arcade or Composio directly.
Reach for nominee when you want policy + approvals + receipts that are framework-neutral, no-SaaS, and bring-your-own-everything — the same policy wherever your agent runs, with the vault swappable underneath.
Quickstart
npm i nominee
The whole setup is a policy and one line around your tools. No provider, no signup.
import { Nominee, allow, deny, ask } from 'nominee'
const nominee = new Nominee({
policy: {
rules: [
allow('email.read'),
allow('email.forward', { when: ({ input }) => input.to.endsWith('@acme.com') }),
deny('email.forward', { reason: 'external forwarding is exfiltration' }),
ask('email.delete'),
],
fallback: 'deny',
},
onApprovalRequest: (req) => notifySlack(req), // req.approve() / req.deny()
})
// One line. Works with plain functions or any framework's { execute } tools.
const tools = nominee.guard({ 'email.read': readEmail, 'email.forward': forwardEmail }, {
user: 'alice',
})
Denied calls throw PolicyDeniedError before the tool runs.
Escalated (ask) calls block until a human decides. Every outcome — allow,
deny, ask, and the eventual approval — lands on the
receipt chain.
When tools need third-party tokens
Tools that act on APIs also need credentials — fresh ones, at call time, never
sitting in the model's context. Add a strategy; it's optional, and a
plain function is the simplest one:
const nominee = new Nominee({
policy, // as above
strategy: ({ connection }) => process.env[`${connection.toUpperCase()}_TOKEN`]!,
})
// at the moment the tool runs — always fresh, cached until just before expiry
const token = await nominee.token({ user: 'alice', connection: 'github' })
Rule of thumb: never store the returned token in your own agent
state. Call nominee.token() at the moment of the call — see
Token freshness for why.
Don't want to bring your own tokens? Let Auth0 manage Token Vault + push approvals,
also one line: new Nominee({ strategy: auth0() }) — see
Strategies.
Policy
Declarative allow / deny / ask rules over
tool calls — the model cannot talk its way past a deny. Semantics are
small enough to hold in your head:
- First match wins within a policy; rules are checked in order.
- No match →
fallback(default'ask'— unknown actions reach a human; set'deny'for default-deny, or'allow'for report-only auditing). whenpredicates see{ tool, input, user, chain }— gate on arguments, not just tool names.- Glob patterns match tool names:
allow('github.*'),deny('*.delete'). - Budgets:
allow('search.*', { max: 20 })— the 21st call escalates to a human instead of failing silently.
import { allow, deny, ask } from 'nominee'
allow('email.forward', { when: ({ input }) => input.to.endsWith('@acme.com') })
deny('email.forward', { reason: 'external forwarding is exfiltration' })
ask('repo.delete', { reason: 'destructive', timeoutMs: 5 * 60_000 })
allow('search.web', { max: 20 }) // budget: call #21 asks a human
Dry-run any call without consuming budgets, asking anyone, or writing a receipt:
await nominee.check({ tool: 'repo.delete', user: 'alice' }) // → { effect: 'deny', ... }
Approvals
Gate sensitive actions. approve() blocks until a decision and throws ApprovalDeniedError if denied or expired.
try {
await nominee.approve({
user: 'alice',
action: 'repo.delete',
detail: { repo: 'alice/old-project' },
timeoutMs: 5 * 60_000, // optional; default waits indefinitely
})
// approved — proceed
} catch (err) {
// ApprovalDeniedError: denied or expired
}
If the strategy implements native approval (e.g. Auth0 CIBA), nominee delegates to it. Otherwise the built-in engine pauses until your webhook resolves it:
// in your Nominee config:
onApprovalRequest: (req) => notifyUser(req), // req.id, req.user, req.action, req.detail
// later, from your approval webhook/handler:
nominee.resolveApproval(req.id, 'approved') // or 'denied'
Receipts
Every decision, approval, and token grant appends to a hash chain — each receipt's hash covers its content plus the previous hash, so editing or deleting any record breaks verification of everything after it. With a signing key, hashes become HMACs, so only key-holders can forge the chain.
const nominee = new Nominee({
policy,
receipts: {
key: process.env.RECEIPT_KEY, // optional HMAC signing
onReceipt: (r) => auditLog.write(r), // stream to your sink
},
})
nominee.receipts // the chain so far
nominee.verifyReceipts() // { ok: true, checked: 128 }
// Later, offline, from your log sink:
import { verifyReceipts } from 'nominee'
verifyReceipts(exported, { key }) // { ok: false, brokenAt: 41, reason: '…' }
By default inputs are recorded as inputHash (SHA-256 of the canonical
JSON) — provable without writing user data into logs.
receipts: { input: 'raw' } or 'none' change that. Pass
receipts: false to disable receipts entirely.
For a durable or hibernating agent that reconstructs its Nominee
instance across restarts (a Durable Object, a resumed job), persist the receipts
yourself and pass resume: { seq, prev } — the sequence number and hash
to continue from — so the new ledger picks up the same chain instead of starting a
second genesis:
const last = persistedReceipts.at(-1)
const nominee = new Nominee({
policy,
receipts: {
key,
onReceipt: (r) => persistedReceipts.push(r),
resume: last ? { seq: persistedReceipts.length, prev: last.hash } : undefined,
},
})
See the live agent demo for a worked example: a Cloudflare Durable Object that hibernates mid-session and resumes the same receipt chain when it wakes.
Delegation
An orchestrator can spawn sub-agents whose policies can only narrow
authority, never widen it. Across a delegation chain, the strictest outcome wins
(deny > ask > allow) — a sub-agent can
never allow what its parent denies.
const researcher = nominee.delegate('researcher', {
policy: [deny('email.*'), deny('github.merge_*')],
})
// researcher's receipts carry chain: ['orchestrator', 'researcher']
The sub-agent shares the parent's token cache, receipt chain, and audit stream, but
every event from it carries the extended identity chain — so a delegated action is
attributable to the exact sub-agent that took it. A sub-agent's own
fallback defaults to 'allow' (its rules are restrictions
layered on top of the chain, not a fresh default-ask).
Audit
Every privileged op emits an event to onAudit / on(). Event type is one of:
policy.decision · token.issued · token.cached · token.error · token.invalidated · token.exchanged · approval.requested · approval.resolved · authz.checked
Each event includes { type, user, agent?, connection?, action?, resource?, decision?, effect?, rule?, reason?, chain?, at, detail? }. effect, rule, and reason are set on policy.decision events — the same fields recorded on the matching receipt. chain is the delegation chain (e.g. ['orchestrator', 'research-agent']), at is epoch ms. Pipe it to durable storage so you can always answer "who authorized this, and on whose behalf?".
Token freshness done right
"Just refresh the token" is a five-line happy path until you meet two things real providers do: short-lived access tokens and rotating refresh tokens (GitHub Apps, Google one-time-use, Okta, Auth0 rotation). Then the naive approaches break:
- Grab-up-front breaks across a pause. An agent that captures the access token, then waits for human approval, acts with an expired token → 401.
- Naive refresh breaks under concurrency. A real agent fires many tool calls at once. Each reads the same stored refresh token and refreshes independently; rotation invalidates the others mid-flight →
invalid_grant, and your stored token can corrupt.
nominee makes both correct, with no change to your tool code:
- Fresh at call time. Cached per (user, connection) until
expiryLeewayMsbefore expiry (default 60s), then refreshed. - Single-flight. Concurrent cache-misses for the same key share one network refresh — no stampede, no rotation race.
- Rotation persisted. When the provider rotates the refresh token, nominee hands it back via
onRefreshTokenso the next refresh uses the live token.
With the OAuth2 strategy, the one line that makes rotation correct is onRefreshToken:
import { Nominee, OAuth2 } from 'nominee'
const nominee = new Nominee({
strategy: OAuth2({
connections: {
github: {
tokenEndpoint: 'https://github.com/login/oauth/access_token',
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
refreshToken: () => store.get('alice').refreshToken, // read from your store
onRefreshToken: (_p, rt) => // write the rotated one back
store.set('alice', { ...store.get('alice'), refreshToken: rt }),
},
},
}),
})
Need to drop a token immediately (e.g. you revoked access upstream)? Call
nominee.invalidate(user, connection); the next token()
re-resolves. token({ force: true }) bypasses the cache for one call.
Strategies
A strategy tells nominee how to get a token — it's the seam that lets you swap vaults without rewriting your agent. Pass one to new Nominee({ strategy }). A plain function is the simplest.
| Strategy | Use it for |
|---|---|
(params) => token | The default. A function returning a string or { token, expiresAt?, scopes? }. |
tokens(fn) | The same, but named — wrap an env var or DB lookup. |
OAuth2({ connections }) | Generic refresh-token flow. Zero deps. Bring your stored refresh tokens; set onRefreshToken for rotating providers. |
Memory({ tokens }) | Seed tokens in memory for dev, examples, and tests. |
Supabase({ url, key }) | Read & refresh provider tokens stored in Supabase. From nominee-supabase. |
auth0() / Auth0({ … }) | Optional managed strategy — Token Vault + CIBA. From nominee-auth0. |
Implementing your own is one method, getToken() — see CONTRIBUTING.md.
Auth0 (optional managed upgrade)
Same API — swap the strategy to get managed Token Vault tokens and CIBA phone approvals, with no change to your agent code. For a single tenant, auth0() reads everything from the environment:
import { Nominee } from 'nominee'
import { auth0 } from 'nominee-auth0'
// reads AUTH0_DOMAIN / CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN / USER_SUB
const nominee = new Nominee({ strategy: auth0() }) // managed Token Vault + CIBA
const token = await nominee.token({ user, connection: 'github' }) // always fresh
Unconfigured, auth0() falls back to a built-in mock (short-TTL token + auto-approve) so examples run with zero setup — set the env and the same call becomes real. For per-request subjectToken, multi-tenancy, or custom CIBA options, use the explicit Auth0({ … }) form.
Framework adapters
Drop nominee into any tool: an adapter wraps it so every call is checked against your policy, gated on approval when asked, and handed a fresh token automatically. Both expose nomineeTool() and withNominee(); nominee-ai also exports guardTools(nominee, tools, { user }) to wrap a whole AI SDK tools object in one line.
import { nomineeTool } from 'nominee-ai' // or 'nominee-eve'
import { z } from 'zod'
const starRepo = nomineeTool({
nominee,
user: 'alice', // string, or a function of the call context
connection: 'github', // inject a fresh token into execute()
approval: true, // pause for human approval first (optional)
action: 'github.star', // name used in the approval prompt + audit
description: 'Star a GitHub repository',
inputSchema: z.object({ repo: z.string() }),
async execute({ repo }, { token, user }) {
await fetch(`https://api.github.com/user/starred/${repo}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
})
return `Starred ${repo}`
},
})
nominee-ai targets the Vercel AI SDK (and Cloudflare Agents, which use
it). nominee-eve targets Vercel Eve and returns a real
defineTool(). The execute context is { token?, user, ai }
for AI SDK and { token?, user, eve } for Eve. The
ai-sdk-minimal
example is the whole integration in ~25 lines.
Core API
| Method | Returns / behavior |
|---|---|
authorize({ tool, input?, user }) | Promise<Authorization> — checks the call against the policy. Resolves when it may proceed; throws PolicyDeniedError / ApprovalDeniedError otherwise. requireApproval: true forces an ask. |
check({ tool, input?, user }) | Promise<PolicyDecision> — dry run: the decision a call would get, with no side effects (no budgets, no approvals, no receipts). |
guard(tools, { user }) | Wraps a whole tools object (plain async functions or { execute } tools) so every call is authorized first — the one-line integration. |
receipts · verifyReceipts() | The hash-chained, optionally HMAC-signed receipt log and its tamper check. verifyReceipts(receipts, { key }) (top-level export) verifies an exported log offline. |
token({ user, connection, scopes?, force? }) | Promise<string> — a fresh token. Cached per (user, connection) until just before expiry; force: true bypasses the cache. |
approve({ user, action, detail?, timeoutMs? }) | Promise<ApprovalResult> — resolves on approval; throws ApprovalDeniedError on deny/expiry. |
resolveApproval(id, 'approved' | 'denied') | boolean — settle a built-in-engine approval from your webhook. Returns false for unknown ids. |
can({ user, action, resource }) | Promise<boolean> — fine-grained authz. Requires a strategy that implements it (else throws). |
invalidate(user, connection) | boolean — drop the cached token so the next call re-resolves. Returns true if an entry was removed. |
delegate(actor, { policy? }) | Nominee — a sub-agent sharing this cache, receipts & audit stream. Extra rules can only narrow authority (deny > ask > allow); its events carry user → … → actor. |
exchange({ user, connection, actor, scopes? }) | Promise<string> — RFC 8693 downscoped token bound to a sub-agent. Requires a strategy that implements it. |
on((event) => ...) | () => void — subscribe to audit events; returns an unsubscribe function. |
Constructor options: policy? (rules array, or { rules, fallback }), receipts? ({ key?, onReceipt? }, or false), strategy?, onApprovalRequest?, onAudit?, approvalTimeoutMs?, expiryLeewayMs? (default 60s), agent?.
Errors
PolicyDeniedError— thrown byauthorize()andguard()-wrapped tools when adenyrule (or the fallback) matches — before the tool runs. Carries the decision and the matched rule.ApprovalDeniedError— thrown byapprove()(and adapter tools withapproval: true) when a request is denied or expires. Carries.result.- Strategy errors propagate from
token()— e.g. a refresh failure or an unknown connection. Atoken.erroraudit event is emitted first. can()throws if the active strategy doesn't implement authorization.
Security model
- Never hold tokens. Call
token()at the moment of use. nominee caches in memory only and never persists third-party tokens itself — your strategy/store owns the refresh token. - Gate the irreversible. Require
approvalfor deletes, payments, outbound mail, and anything you couldn't easily undo. - Keep the audit stream. Pipe
onAuditto durable storage so you can always answer "who authorized this?". - Attribute delegated actions. Use
delegate()/exchange()so a sub-agent's actions carry the full identity chain. - Report vulnerabilities privately — see SECURITY.md.
Migrating from 2.0
2.1 is fully additive — nothing from 2.0's constructor options or method signatures
changed shape. A 2.0 nominee that only used strategy,
token(), approve(), and onAudit keeps working
exactly as written.
What's new to opt into:
policy— add rules and callauthorize()/guard()to start enforcing before tools run, instead of only brokering tokens.receipts— on by default (with a fresh, empty chain) unless you passreceipts: false.delegate()— new, for sub-agent narrowing.
One behavior note: nomineeTool's approval: true (in
nominee-ai and nominee-eve) now routes through
authorize({ requireApproval: true }) under the hood instead of calling
approve() directly — the user-visible behavior (blocks until a human
decides, throws ApprovalDeniedError on denial) is unchanged, but the
decision and the approval now both land on the receipt chain.