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.

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).
  • when predicates 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 expiryLeewayMs before 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 onRefreshToken so the next refresh uses the live token.
See the runnable proof — naive concurrent + rotating refresh fails 7/8, nominee gets 8/8 with the same agent code: examples/token-refresh-correctness.

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.

StrategyUse it for
(params) => tokenThe 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

MethodReturns / 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 by authorize() and guard()-wrapped tools when a deny rule (or the fallback) matches — before the tool runs. Carries the decision and the matched rule.
  • ApprovalDeniedError — thrown by approve() (and adapter tools with approval: true) when a request is denied or expires. Carries .result.
  • Strategy errors propagate from token() — e.g. a refresh failure or an unknown connection. A token.error audit 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 approval for deletes, payments, outbound mail, and anything you couldn't easily undo.
  • Keep the audit stream. Pipe onAudit to 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 call authorize() / guard() to start enforcing before tools run, instead of only brokering tokens.
  • receipts — on by default (with a fresh, empty chain) unless you pass receipts: 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.