Documentation
The authorization layer for AI agents. Your agent logs in as you — nominee decides what it can do as you. Bind application authorization, exact-call capability, credential delivery, and durable evidence — dependency-free core, framework-neutral, self-hostable.
What nominee is
nominee sits between the model and your tools. The primary run()
path checks application resource authorization and declarative
allow / deny / ask policy, reserves
budgets, pauses when needed, consumes one exact-input capability, resolves a
credential, runs the side effect once, and records its outcome.
run({ user, tenant, tool, resource, input }, execute)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 need application authorization, constrained credential delivery, and evidence that are framework-neutral and self-hostable — the same enforcement contract wherever your agent runs.
Framework integrations
Read the framework-specific integration guides in the repository:
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.
An ask can resolve inline or surface ActionPendingError
with a durable action id for later resume. 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`]!,
})
// Decision-bound: credential resolved inside execute after capability consumption
await nominee.run(
{ tool: 'github.issue.close', input: { repo, issue }, user: 'alice', connection: 'github' },
({ token }) => closeIssue({ repo, issue, token }),
)
Rule of thumb: never store the returned token in your own agent
state. Under run(), the strategy resolves credentials inside the
execute callback — see Token freshness for why.
Standalone nominee.token() remains for dev; it is disabled under
production: true.
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, tenant, resource, chain }— gate on trusted application context and 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
The legacy single-process approve() API 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'
For a durable workflow, use prepareAction(). Persist the pending
action id, settle it with resolveActionApproval() (or poll a provider
with resumeAction()), then execute the returned one-use capability.
run() and framework adapters throw ActionPendingError
when that approval outlives the current request.
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 multi-replica production, use the atomic receipt store from
nominee-postgres. It sequences one stream transactionally:
const control = new PostgresControlStore(postgresDatabase(pool))
const nominee = new Nominee({
production: true,
policy,
actionStore: control,
receipts: {
store: control,
stream: `tenant:${tenantId}`,
key,
delivery: 'strict',
},
})
The action store also persists budgets, approvals, capabilities, outcomes, and an immutable transition journal. In-memory stores remain available for local work. Anchor signed stream tips outside the primary database when the threat model includes rollback of both receipts and their internal checkpoint.
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: every official adapter routes the side effect through the decision-bound action lifecycle and resolves credentials only after capability consumption.
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.
| Runtime | Package |
|---|---|
| Vercel AI SDK / Cloudflare Agents | nominee-ai |
| Vercel Eve | nominee-eve |
| OpenAI Agents SDK | nominee-openai — ask rules use native resumable approval |
| Mastra | nominee-mastra — native or portable approval |
| MCP server SDK | nominee-mcp — registered decision-bound handlers |
Production
Set production: true on consequential paths. Nominee refuses to
start without a default-deny policy, durable action state, an atomic durable
receipt store, strict receipt delivery, and durable state for provider-native
approvals.
const control = new PostgresControlStore(postgresDatabase(pool))
const nominee = new Nominee({
production: true,
policy: { rules, fallback: 'deny' },
policyVersion: '2026-07-29.1',
actionStore: control,
receipts: { store: control, stream: `tenant:${tenant}`, key, delivery: 'strict' },
authorizer: ({ user, action, resource, tenant }) =>
appAuthz.can({ user, action, resource, tenant }),
strategy: credentialBroker,
})
Use the Nominee action id as the downstream API idempotency key. Keep raw tools and root credentials outside model-controlled code. See the repository production runbook for recovery, monitoring, and known boundaries.
Core API
| Method | Returns / behavior |
|---|---|
run({ tool, input, user, resource?, tenant?, connection?, scopes? }, execute) | Recommended decision-bound path: authorize, issue and consume a single-use capability, resolve the credential, execute, and record the outcome. |
prepareAction() · resumeAction() · executeCapability() | Durable pause/resume API for jobs, approvals, and cross-process execution. |
resolveActionApproval(actionId, resolution) | Resolve a durable dashboard or webhook approval with approver identity and mechanism. |
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. |
await assertUnchanged(authorization, input) | Verifies that the input about to execute matches the canonical fingerprint evaluated by policy and any approver. Official wrappers call it automatically. |
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() · flushReceipts() | The hash-chained, optionally HMAC-signed receipt log, tamper check, and async sink checkpoint. delivery: 'strict' waits for the sink before async authorization returns. |
token({ user, connection, scopes?, force? }) | Promise<string> — a fresh token. Cached per canonical (user, connection, scope set) 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 also include actionStore?, authorizer?, policyVersion?, production?, and onGovernedAction?. Use usageReporter() for opt-in pseudonymous governed-principal measurement.
Errors
PolicyDeniedError— thrown byauthorize()andguard()-wrapped tools when adenyrule (or the fallback) matches — before the tool runs. Carries the decision and the matched rule.ActionPendingError— a decision-bound action is durably waiting for approval. Carries.actionIdand.approvalId.ApprovalDeniedError— thrown byapprove()orrun()when a request is denied or expires. Carries.result.AuthorizationInputChangedError— thrown when the arguments about to execute differ from those authorized.ActionOutcomePersistenceError— a governed action reached or needed to record a terminal result, but its durable outcome or evidence failed to persist. Inspect the action and downstream idempotency state before retrying.- 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
- Use the decision-bound path.
run()or prepare/resume/execute binds the exact call through outcome; legacyauthorize()and unboundtoken()are disabled in production mode. - Use durable stores.
nominee-postgresmakes budgets and capability consumption atomic and sequences receipts across replicas. - Keep root authority outside the runtime. Only the execution callback receives the resolved credential after capability consumption.
- Make downstream writes idempotent. Use the action id as the third-party API idempotency key.
- Attribute delegated actions. Use
delegate()/exchange()so a sub-agent's actions carry the full identity chain. - Report vulnerabilities privately — see SECURITY.md.
Migrating to 2.2
Existing non-production uses of authorize(), token(),
approve(), and onAudit remain available. The 2.2
adapters now execute through the decision-bound action lifecycle, so upgrade
nominee and its adapters together.
What changed:
run()and prepare/resume/execute bind policy, resource authorization, approval, capability, credential, exact input, and outcome.- Long-lived adapter approvals may now throw
ActionPendingError; persist the action id and resume it instead of holding a process open. production: truedisables unboundauthorize()/token()and requires default-deny policy plus durable action, receipt, and provider-approval state.nominee-postgres,nominee-openai,nominee-mastra, andnominee-mcpare new first-class packages.