nominee-ai
v3.0.0
Published
One-line guardTools for Vercel AI SDK and Cloudflare Agents — user, tenant, resource, connection, scopes flow into policy and fresh tokens.
Maintainers
Readme
Installation
npm i nominee nominee-aiAlso works with Cloudflare Agents — they use the same AI SDK internals.
Observe Before Enforcing
Pass new Nominee({ mode: 'observe' }) to guardTools, nomineeTool, or
withNominee to inventory the tool callbacks that actually run before writing
a policy. The adapter still routes through run(), but denies, asks, budgets,
and approval: true are recorded rather than enforced. Observation reports do
not retain raw string/boolean values or user IDs; numeric aggregates may be
sensitive. Observe mode is not a security control and cannot be combined with
production: true.
How It Works
flowchart LR
LLM["LLM decides to\ncall a tool"] --> G["guardTools() /\nnomineeTool()"]
G --> RUN["nominee.run()\ndecision-bound path"]
RUN --> P{"policy:\nallow / deny / ask"}
P -->|deny| X["PolicyDeniedError\n(tool never runs)"]
P -->|ask| AP["⏸ wait for a\nhuman decision"]
AP -->|pending| APE["ActionPendingError\n(durable action id)"]
AP -->|approved| CAP["consume capability\n(exact input hash)"]
P -->|allow| CAP
CAP --> TOK["strategy resolves\ntoken (optional)"]
TOK --> EX["execute(input, ctx)"]
EX --> R["receipt appended\nhash-chained record"]Every call routes through nominee.run() — the decision-bound path that binds authorization to a fingerprint of the arguments and issues a single-use capability before execute runs. Denied calls throw PolicyDeniedError; ask calls block until a human decides or surface ActionPendingError with a durable action id when the approval outlives the request; every outcome (including refusals) lands on the receipt chain.
Quickstart — guardTools
Wrap your existing AI SDK tools object in one line. The object key is the tool name your policy matches on:
import { Nominee, allow, deny, ask } from 'nominee'
import { guardTools } from 'nominee-ai'
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
const nominee = new Nominee({
policy: {
rules: [
allow('searchEmail'),
deny('forwardEmail', { reason: 'external forwarding is exfiltration' }),
ask('mergePr'), // a human decides, every time
],
fallback: 'deny',
},
onApprovalRequest: (req) => notifySlack(req), // req.approve() / req.deny()
})
const { text } = await generateText({
model: openai('gpt-4o'),
tools: guardTools(nominee, { searchEmail, forwardEmail, mergePr }, { user: 'alice' }),
prompt: 'Triage my inbox',
})Your tools are unchanged — guardTools intercepts each execute, calls nominee.run() with the call's full context, and only then runs the original. Client-executed tools (no execute) pass through untouched.
Full context, still one line
The third argument carries the same per-call context as nomineeTool. user can be an async resolver of the tool-call options: (options) => session.userId. resource and tenant can be static values or resolvers of (input, options), and connection / scopes feed your tokens strategy. Every resolved value reaches nominee.run(), so tenant- and resource-scoped policy rules, external authorization, and token strategies all see it:
const tools = guardTools(
nominee,
{ searchEmail, forwardEmail, deleteRepo },
{
user: 'alice',
tenant: 'acme', // or (input, options) => session.tenant
resource: (input) => input.to?.mailbox, // only this mailbox, per call
connection: 'google', // fresh token for this connection at call time
scopes: ['gmail.send'],
},
)
// Policy can then scope on the context the one-liner carries:
// allow('email.forward', { when: ({ tenant }) => tenant === 'acme' })Note:
connection/scopesonguardToolsauthorize a fresh token through your strategy (policywhenclauses, external authorization, and the receipt log all see it), but the wrapped tool's plain AI SDKexecutereceives only(input, options)— it never sees the token. When the tool itself must call the third-party API, usenomineeTool, whoseexecutereceives the fresh token inctx.token. And because resolving a token requires a configured strategy, aconnectionon a policy-only nominee fails closed at call time.
Which path to use when
guardTools— wrap your whole existing tools object with one shared context:user,resource,tenant,connection,scopesfor every tool. Your tools keep their plain AI SDKexecutesignature — they do not receivectx.token;connection/scopesthere are for authorization and audit, not for handing the tool a secret.nomineeTool— per-tool config: a differentconnection/scopes/approval/ policyactionper tool, and the fresh token injected intoctx.tokenwhere yourexecuteactually consumes it.
nomineeTool — Per-Tool Config
When each tool needs its own connection / scopes, a forced approval, its own policy action name, or the fresh token inside ctx.token, build it with nomineeTool:
import { nomineeTool } from 'nominee-ai'
import { z } from 'zod'
const starRepo = nomineeTool({
nominee,
user: 'user_123',
connection: 'github', // fresh token injected into ctx.token at call time
action: 'github.star', // the tool name your policy rules match on
description: 'Star a GitHub repository',
inputSchema: z.object({ repo: z.string() }),
execute: async ({ repo }, ctx) => {
await fetch(`https://api.github.com/user/starred/${repo}`, {
method: 'PUT',
headers: { Authorization: `Bearer ${ctx.token}` },
})
return `Starred ${repo}`
},
})The policy is enforced here too — nomineeTool routes through nominee.run() on action (default "tool") before execute runs, then resolves the token via your nominee strategy inside the capability callback (fresh at call time, single-flight refresh). Pass resource, tenant, and scopes when your policy or strategy needs them.
Forcing an Approval
approval: true forces an ask even when the policy allows the call — the tool pauses until a human decides, and a denial throws ApprovalDeniedError before execute:
const deleteRepo = nomineeTool({
nominee,
user: 'user_123',
connection: 'github',
approval: true, // ⏸ pauses until a human approves
action: 'repo.delete',
description: 'Delete a GitHub repository',
inputSchema: z.object({ repo: z.string() }),
execute: async ({ repo }, ctx) => {
// Only runs after explicit human approval
await fetch(`https://api.github.com/repos/${repo}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${ctx.token}` },
})
return `Deleted ${repo}`
},
})For rule-driven escalation (ask('repo.delete'), argument-level when conditions, maxCalls budgets), put it in the policy instead — the decision and its resolution are sealed into the receipt chain either way.
What happens on ask
ask rules (and approval: true) route through nominee.run(). If a human settles the approval inline — e.g. your onApprovalRequest calls req.approve() within the same request — the tool runs right away. If the approval outlives the request (the callback only notifies, a CIBA push is still pending, or the process goes away first), the tool's execute throws ActionPendingError with a durable actionId instead of hanging — and the tool never runs. Catch it where the call is made, persist the actionId and the original input (the durable action record stores only an input hash), then resume later with resolveActionApproval() → resumeAction() → executeCapability(). Full walkthrough: Approvals that outlive the request.
withNominee — Set Defaults Once
Apply a shared nominee instance and user context across all tools in one call:
import { withNominee } from 'nominee-ai'
const nomineeTool = withNominee(nominee, {
user: 'user_123', // or an async resolver of the tool-call options
})
// All tools share the same user by default
const starRepo = nomineeTool({
connection: 'github',
description: 'Star a repository',
inputSchema: z.object({ repo: z.string() }),
execute: async ({ repo }, ctx) => starRepoForUser(repo, ctx.token),
})Tool Context
The execute function of a nomineeTool receives a rich context object:
execute: async (input, ctx) => {
ctx.token // string — fresh token for the configured connection (if any)
ctx.user // string — the resolved principal
ctx.ai // the raw AI SDK tool context (messages, toolCallId, etc.)
}TypeScript
Full generics are preserved end-to-end:
const tool = nomineeTool({
inputSchema: z.object({ repo: z.string() }), // input is typed as { repo: string }
execute: async ({ repo }, ctx) => { // return type is inferred
return { starred: repo, at: new Date() }
},
})guardTools preserves the type of the tools object you pass in.
CommonJS + ai@7 (Node version floor)
nominee-ai ships both ESM (dist/index.js) and CJS (dist/index.cjs).
The peer range is ai: ^5 || ^6 || ^7. From ai@7 the AI SDK is ESM-only:
there is no require export. dist/index.cjs's require('ai') works only
because Node ≥ 22.12 added synchronous require() of ESM. A CJS consumer
on older Node, or a bundler that resolves exports strictly at build time
(webpack, ts-jest in CJS mode), will fail to load the .cjs entry once
ai@7 is the resolved peer. ai@5 and ai@6 still ship a real require
condition.
Prefer the ESM entry (import from nominee-ai). If you must require(),
use Node ≥ 22.12, or stay on ai@5/ai@6. This is a packaging trap, not a
policy-behavior change. Full notes: docs/adapter-compatibility.md.
