Vercel's AI SDK shipped something worth taking seriously: toolApproval. Attach it to generateText, streamText, or a ToolLoopAgent, and a tool call can come back as 'user-approval' instead of running — the model's turn ends, your app shows the pending call, a human decides, and you resume with a tool-approval-response. Pair it with experimental_toolApprovalSecret and the SDK HMAC-signs the approval at issuance, binding the signature to the exact tool name, call id, and input arguments — change any of them before the approval is replayed and the signature breaks. That's real engineering, not a checkbox.
It's also, by the SDK's own documentation, doing one specific job: deciding whether a human saw this call before it ran. It says so directly — per-tenant resource permissions aren't evaluated by the approval system itself; you implement that policy yourself inside the approval function. It doesn't issue a credential — approvals are state markers, not authorization tokens. And it lives inside one SDK's tool-calling loop, so the instant your agent also has an MCP server, a Mastra tool, or a bare queue worker doing the actual mutation, the approval state doesn't travel with it.
None of that is a flaw in the SDK. It's scope. The question is what sits underneath it.
What toolApproval verifies, and what it doesn't
Read the guarantee experimental_toolApprovalSecret actually gives you: the server signed this tool, this call id, this input, and rejects a replayed approval whose signature doesn't match — fail-closed. That's the right primitive for "don't let the client forge a yes." It says nothing about whether this user was entitled to do this thing to this tenant's resource in the first place. That check — the one the docs push into your own approval function via runtimeContext — is exactly the boundary nominee's authorizer hook exists for, and nominee runs it twice: once while planning the action, and again immediately before the credential resolves and the tool executes.
// packages/core/src/nominee.ts — executeCapability(), after the capability
// is atomically consumed, before token resolution:
if (action.resource) {
currentAuthorization = await checkExternalAuthorization({ user, action, resource, tenant, inputHash })
// a permission revoked while approval was pending fails closed here —
// the tool never runs
}
That recheck exists as a named test in the core suite — packages/core/test/action.test.ts, "rechecks resource authorization after approval and before execution" — because the gap it closes is real: an approval that takes ten minutes to resolve is ten minutes in which the resource's permissions can legitimately change.
Where the credential comes from
Say the tool needs a GitHub token to actually close the issue. toolApproval doesn't have an opinion about that — the token is however you already had it in scope, held for however long your process holds it. nominee's decision-bound path treats the credential as part of the same authorization, not a separate concern:
import { nomineeTool } from 'nominee-ai'
const closeIssue = nomineeTool({
nominee,
user: 'user_123',
connection: 'github', // fresh token, resolved at call time
resource: (input) => `repo:${input.repo}`, // checked by nominee's authorizer
approval: true,
action: 'close_issue',
inputSchema: z.object({ repo: z.string(), issue: z.number() }),
async execute({ repo, issue }, { token }) {
// token is fresh — fetched only after approval clears and the
// capability is consumed, never held in the model's context
},
})
Underneath, nomineeTool routes every call through nominee.run() — plan the action, evaluate policy, clear approval, issue a single-use capability, resolve the credential under that capability's scope ceiling, execute exactly once, seal a receipt. The capability itself is bound to a fingerprint of the input; if the arguments change between authorization and execution, executeCapability() throws AuthorizationInputChangedError before the tool runs, receipt and all.
One honest caveat: today, nomineeTool's approval: true does not hook into the SDK's native toolApproval/tool-approval-request message round-trip. It runs nominee's own approval engine in-process, inside execute — a webhook, Slack, or an Auth0 CIBA push resolves it, and the call blocks (or, via prepareAction(), surfaces a durable action id you resume out of band with ActionPendingError if the process can't hold the request open). Same shape as the SDK's pause-and-resume, different plumbing, and not yet the same wire protocol. If you're building fresh, that's worth knowing going in.
The part that doesn't fit inside one SDK
A toolApproval config lives on a generateText/streamText call. It's real for exactly the tools invoked through that call. The moment your system also runs an MCP server that an internal team hits directly, or a Mastra workflow, or an OpenAI Agents SDK loop for a different surface — the approval state, the input-binding guarantee, and the evidence all reset to zero in each one, because each is a separate SDK with its own approval primitive (or none).
nominee's policy, capability, and receipt semantics are the same object enforced identically across all of them — nominee-ai, nominee-eve, nominee-mastra, nominee-openai, nominee-mcp, nominee-langchain — each with its own adapter test suite (packages/{ai,eve,mastra,openai,mcp,langchain}/test/) built against the same core lifecycle in packages/core/test/action.test.ts. A tool guarded once behaves the same whether the LLM in front of it is running inside Vercel's loop this month and an MCP client next quarter.
And every decision — including refusals — lands on a hash-chained receipt, not just the pending-approval turns. A denied call, an approval that timed out, a resource check that failed after a pause: all of it is on the same evidence chain as the calls that succeeded, verifiable with verifyDurableReceipts(). toolApproval's signed request proves the approval wasn't forged in that one turn; it isn't a durable log of every decision the system made.
Where nominee begins
| Property | AI SDK toolApproval | nominee |
|---|---|---|
| Pauses for a human | ✓ | ✓ |
| Signature binds tool + call id + input | ✓ (with the secret configured) | ✓ (exact-input hash, always) |
| Per-tenant resource entitlement | You write it, in the approval function | Built in — authorizer hook |
| Rechecked after the pause, before execution | ✗ | ✓ |
| Issues a scoped, fresh credential | ✗ | ✓ — call-time, action-scoped |
| Portable outside this one SDK's loop | ✗ | ✓ — same lifecycle, five adapters |
| Durable, tamper-evident log of every decision | ✗ | ✓ — hash-chained receipts |
toolApproval and nominee aren't competing for the same job. One decides whether a human saw a call. The other decides whether the call may execute — for a specific user, against a specific resource, with a credential scoped to exactly that action — and keeps proof either way.
When you don't need this
If your entire agent lives inside one AI SDK server process, your resource checks are simple enough to write inline in a toolApproval function, your tools don't touch third-party credentials, and a per-turn signed approval is evidence enough for your use case — toolApproval plus experimental_toolApprovalSecret is a solid, small, well-built answer on its own. Don't add a layer you don't need.
Reach for nominee when the same authorization has to hold across more than one framework, when "who can do this to which tenant's resource" is a real access-control question and not a config flag, or when an auditor is eventually going to ask for the receipt, not just the last message in a chat transcript.
See it for yourself
Guard your AI SDK tools with one line.