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 { 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
tools: {
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 answerstoolsmaps AI SDK tool names to a permission reference and, for instance-level actions, adataresolver that loads the resource from the tool arguments.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 tool thetoolsmap does 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,
tools: { 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
toolsare 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).onDenied({ 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.wrapwraps each instance; build it withwrapPermDock(wrapping an instance).
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 |
| unmapped tool, validation error, thrown error | denied | fail closed |
- On
user-approval, the human's answer returns as an approval response. 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.
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.