AI SDK
permdock/ai-sdk turns PermDock decisions into Vercel AI SDK 7 tool approvals, capability middleware and WorkflowAgent suspensions, fail-closed by construction.
permdock/ai-sdk connects the Decision model to the approval vocabulary of the Vercel AI SDK 7: toolApproval on generateText, streamText and ToolLoopAgent, a language-model middleware that narrows the tool list before the model sees it, and needsApproval for WorkflowAgent, the one place where that option is not deprecated.
Purpose
AI SDK 7 moved tool approval out of individual tools and into a single toolApproval callback that returns approved, denied, user-approval or not-applicable (Tool Approvals, deprecation commit). Vercel's reference policy adapter, @ai-sdk/policy-opa (Policy-Based Tool Approvals), evaluates Rego and includes opaCapabilityMiddleware to trim the tool list; it also fails open when a decision is unrecognised (vercel/ai#19978). permdock/ai-sdk offers the same three integration points with typed permission references and a fail-closed mapping: every path ends in approved, denied or user-approval, never not-applicable.
API
import { defineOperations } from 'permdock'
import { createPermDock } from 'permdock/ai-sdk'
import { z } from 'zod'
const PostArgs = z.object({ id: z.string() }) // tool input arrives as `unknown`
const { toolApproval, capabilityMiddleware, needsApproval } = createPermDock(policy, {
subject: ({ runtimeContext }) => runtimeContext.user,
actor: ({ runtimeContext }) => ({ id: runtimeContext.agentId, kind: 'ai-sdk' }),
delegation: ({ runtimeContext }) => ({ scopes: runtimeContext.scopes }), // what the user handed the agent
operations: defineOperations(permissions, {
delete_post: { permission: permissions.post.delete, data: (args) => loadPost(PostArgs.parse(args).id) },
list_posts: { permission: permissions.post.list },
}),
})
generateText({ model, tools, toolApproval })
// granted → 'approved'; denied → 'denied' (reason + alternatives); approval-required → 'user-approval'
wrapLanguageModel({ model, middleware: capabilityMiddleware({ user, agentId }) })
// narrows `tools` to what this subject may call before the model sees them
tool({ ..., needsApproval: needsApproval(permissions.post.delete) })
// WorkflowAgent: durable suspend until a human answersoperationsmaps AI SDK tool names to a permission reference and, for instance-level actions, adataresolver that loads the resource from the tool arguments. Build it withdefineOperations(operations); the same map serves the other agent adapters.subjectandactorread fromruntimeContext(or any per-call context the caller provides), so one configuration serves many tenants when returned fromprepareCall.delegationis what the user handed the agent:scopes,authorizationDetailsoraccess. It has no default. With anactorand nodelegation, every tool is denied with reasonno-delegation, andcapabilityMiddlewareoffers the model no tools. A resolver that throws counts as no delegation.toolApprovalis a plain function compatible with the AI SDK signature;capabilityMiddleware(context)returns aLanguageModelMiddleware(specificationv4) for that caller, because the middleware sees only the model parameters and no per-call context;needsApproval(permission)returns the predicateWorkflowAgentexpects. A boolean cannot say "denied", so the predicate returnsfalseonly when granted,truefor approval-required, and throwsPermDockDeniedErrorfor a denial or an unmapped call (the AI SDK rejects the run). On the re-check after an approval, it throwsPermDockApprovalRequiredErrorunless the approval is recorded.unmappeddecides what happens to a tooloperationsdoes not bind to a permission:'deny'(the default) hides it incapabilityMiddlewareand denies it intoolApproval;'allow'keeps it in both, for an app that registers tools of its own that need no permission.composeToolApproval(app)returns a tool approval function that runs PermDock'stoolApprovalfirst and asksapp, the application's own approval function, only for a call PermDock grants (or an unmapped tool under'allow'), returning its answer as is.undefinedstaysundefined, which the AI SDK reads as not applicable, so the tool's ownneedsApproval(or the SDK default) decides; return'approved'to approve explicitly. An answer that is not a tool approval result denies, so a broken app function never approves. PermDock'sdeniedanduser-approvalare never passed toapp, so a registry confirmation composes with PermDock without a wrapper:
const { composeToolApproval, capabilityMiddleware } = createPermDock(policy, {
subject: (context) => context.user,
operations: defineOperations(permissions, {
delete_post: { permission: permissions.post.delete },
}),
unmapped: "allow", // the app's own tools without a permission pass through
});
generateText({
model,
tools,
toolApproval: composeToolApproval(({ toolCall }) =>
toolCall.toolName === "send_email" ? "user-approval" : undefined,
),
});- Tool names not present in
operationsare treated as unmapped and denied unlessunmappedis'allow'(see fail-closed rules below). context(context)returns plain JSON merged intosubject.contextunder the policy'scontext, from the same per-call contextsubjectreads. Only values the server derived belong there, never model output; a throw adds nothing and reportson('error')(request data).deniedText({ decision, permission, text })may replace the denial reason or the approval summary the model reads, and nothing else;undefined, an empty string or a throw keeps PermDock's text.oteltakes(permdock) => withOtel(permdock, options)frompermdock/oteland wraps each instance first (OpenTelemetry).wrapwraps each instance afterotel; build it withwrapPermDock(wrapping an instance).stepUpturns a denial the user can lift into auser-approvalpause, so the user can step up instead of the model reading a refusal. Two denials qualify. The first isinsufficient-user-authentication, which asks for a fresh sign-in. The second is a denial that activating a role the user iseligiblefor would grant (elevated access). WithoutstepUp, both staydenied.
const { toolApproval, capabilityMiddleware } = createPermDock(policy, {
subject: ({ runtimeContext }) => runtimeContext.user,
operations,
stepUp: { at: "https://app.example.com/reauth" }, // optional sign-in page
});
await toolApproval(call);
// { type: 'user-approval',
// reason: 'Sign in again to use post.delete: https://app.example.com/reauth?acr_values=mfa&max_age=300',
// stepUp: { kind: 'authentication', acrValues: ['mfa'], maxAge: 300 } }
// { type: 'user-approval',
// reason: 'Activate the admin role on organization T to use post.archive.',
// stepUp: { kind: 'activation', role: 'admin', scope: 'organization', id: 'T' } }- The AI SDK shows
reasonon the approval request;stepUpis there for an app that wrapstoolApprovaland renders its own control.atis the page the user signs in again at. PermDock adds the RFC 9470acr_valuesandmax_agethe failing grants ask for, and leaves the URL out whenatis absent or does not parse. For an activation, the app callsactivate(permdock, { role, scope, id, reason })and writesdecision.elevation. The step-up status carriesstepUpinstead oftoken: there is no approval request in the store, so narrow with'token' in statusbefore readingtoken. WithstepUp,needsApprovalsuspends aWorkflowAgentfor the same denials, andcapabilityMiddlewarekeeps the tools an eligible activation would open.deniedTextdoes not rewrite the step-upreason, because the user reads it, not the model.
Request lifecycle
- Before the model call, the middleware from
capabilityMiddleware(context)builds a request-scopedPermDockfrom that context and removes tools whose permission has no grant for this subject. The model cannot plan with tools it may not use. - The model emits a tool call. AI SDK invokes
toolApprovalwith the tool name and arguments. - The adapter validates the arguments against the resource schema when
datais declared (boundary validation), loads the resource, and callspermdock.decide(permission, data). - The Decision maps to the AI SDK vocabulary:
| Decision outcome | toolApproval result | Notes |
|---|---|---|
granted | approved | tool executes |
denied | denied | reason carries denials and alternatives for the model |
approval-required | user-approval | UI or workflow asks a human; Decision.token attached |
denied a step-up lifts, with stepUp set | user-approval | the user signs in again or activates a role; stepUp |
| unmapped tool, validation error, thrown error | denied | fail closed |
- On
user-approval, the human's answer returns as an approval response. After a step-up, the re-check decides again on the subjectruntimeContextnow carries:grantedanswersapprovedand anything elsedenied, so the user's answer alone never runs the tool. AI SDK then callstoolApprovalagain for the sametoolCallId; the adapter recognises this re-check from thetool-approval-requestinmessagesand answersdeniedunless a matching approval is recorded instore(the SDK treats any other answer as approved). The adapter re-runsdecideand comparesDecision.token(hash of permission key, resource id, subject, actor) with the token issued in step 4; a mismatch is denied. This is the problemexperimental_toolApprovalSecretaddresses by signing approval payloads; PermDock adds the check that the approved call is the same call. - Every step emits
on('decision')so audit logging andpermdock/otelsee the same events as any other adapter.
WorkflowAgent follows the same path but suspends the durable workflow at step 5 instead of returning to the caller; on resume, the token check runs before the tool executes.
What it validates
- Tool arguments against the resource's Standard Schema when a
dataresolver exists (validate: 'boundary'); invalid arguments are a denial with a validation reason, not an exception in the agent loop. - The subject comes from
runtimeContext, never from model output or tool arguments. - Approval replays: token equality on resume, with the token read from
permdockApprovalon the context. An approval resumes one call; a replay is denied withapproval-consumed, and a token issued for another call is ignored. - Tool coverage: in development, the adapter warns when
toolspassed togenerateTextinclude names missing from thetoolsmap, because those calls will be denied at runtime.
How denials surface
deniedresults include areasonstring built from the Decision: the failing role reasons and thealternativeslist (permitted permissions on the same resource), so a model can choose a permitted action instead of retrying the denied one.user-approvalresults carryDecision.tokenand a human-readable summary for the approval UI.- Nothing ever maps to
not-applicable. If the adapter cannot decide (unknown tool, thrown resolver, malformed context) it returnsdeniedand logs the cause. This is the intentional contrast with@ai-sdk/policy-opa, where unrecognised decisions execute the tool (vercel/ai#19978). capabilityMiddlewaredenials are silent by design: the tool is absent from the request. SetonDecisionon the instance to log them. The middleware only removes tools; it never rewrites tool descriptions.- The AI SDK result type is a string, so the Decision is rendered into
reason; the structured Decision stays available to audit throughon('decision'). A plan pre-flight ispermdock.simulate()called by the application before the loop; the adapter adds no hook for it.
Example app
apps/examples/ai-sdk-agent: HTTP harness on 127.0.0.1:3472 with GET /health. GET /list_posts calls toolApproval and returns approved. GET /delete_post returns user-approval. No model API key. tests/integration runs a multi-step streamText loop with a scripted model against a Postgres ApprovalStore: a granted step runs, a denied one is skipped, the owner's approval resumes the gated call once on a fresh instance, and replaying the approval response runs nothing.
Why
- A step-up is an option on the factory, not a wrapper. The AI SDK has one place a tool call pauses for a person, the
user-approvalstatus oftoolApproval. Another function aroundtoolApprovalwould be a second integration point that apps forget to install. Thewith*prefix is reserved for the Supabase middleware pipeline,withSubjectandwithOtel(naming).stepUpis the namepermdock/mcpalready gives the same idea, so one word means "send the user to raise their own access" on both runtimes. It is opt-in because it changes what a denial looks like to a running loop. - Only denials the user can lift step up. A fresh sign-in lifts
insufficient-user-authentication. Activating a role the subject is alreadyeligiblefor lifts a denial that a simulated decision with that role's elevated membership grants.activatemust also accept the role, or only ask for a reason or a sign-in. A matched deny, a validation failure, an anonymous caller, a tool the actor was not delegated and a failed approval resume staydenied, because nothing the user does in the moment changes them. Purpose of use and break-glass are not offered:context.purposecomes from the server, never from a chat prompt. - The step-up never grants. It pauses the loop and names what the user can do; the access comes from a new sign-in or a written elevated membership that
subjectreads on the next call. The re-check decides again against the server-derived subject and approves onlygranted, the same rule an approval resume follows. An activation that needs approval goes through the approval flow ofactivateand its own token. ThestepUphint is computed from the subject's own memberships, never from model output. - Only the AI SDK adapter takes it today. The shared agent kernel can set
stepUpon a deniedOperationVerdict, and onlypermdock/ai-sdkturns it on. Claude Agent, Eve and OpenAI each pause through a different hook, and mapping them is a separate change.
Related standards
- Approvals:
approval: 'human', replay-safetoken, surfaces per runtime. - Delegation: the
actorhalf of the subject for agent runs. - OWASP Agentic Top 10: ASI02 Tool Misuse mitigations via per-tool permissions and argument validation.
- MCP adapter and Claude Agent adapter: the same Decision mapped to other runtimes.
Last updated on
MCP
permdock/mcp guards MCP tools with typed permissions, scope step-up challenges, per-caller tool lists, boundary-validated arguments and model-readable refusals.
Claude Agent SDK
permdock/claude-agent answers Claude Agent SDK canUseTool callbacks and PermissionRequest hooks from PermDock decisions, mapping built-in tools such as Bash to typed permissions.