PermDock
Adapters

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 answers
  • tools maps AI SDK tool names to a permission reference and, for instance-level actions, a data resolver that loads the resource from the tool arguments.
  • subject and actor read from runtimeContext (or any per-call context the caller provides), so one configuration serves many tenants when returned from prepareCall.
  • delegation is what the user handed the agent: scopes, authorizationDetails or access. It has no default. With an actor and no delegation, every tool is denied with reason no-delegation, and capabilityMiddleware offers the model no tools. A resolver that throws counts as no delegation.
  • toolApproval is a plain function compatible with the AI SDK signature; capabilityMiddleware(context) returns a LanguageModelMiddleware (specification v4) for that caller, because the middleware sees only the model parameters and no per-call context; needsApproval(permission) returns the predicate WorkflowAgent expects. A boolean cannot say "denied", so the predicate returns false only when granted, true for approval-required, and throws PermDockDeniedError for a denial or an unmapped call (the AI SDK rejects the run). On the re-check after an approval, it throws PermDockApprovalRequiredError unless the approval is recorded.
  • unmapped decides what happens to a tool the tools map does not bind to a permission: 'deny' (the default) hides it in capabilityMiddleware and denies it in toolApproval; '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's toolApproval first and asks app, the application's own approval function, only for a call PermDock grants (or an unmapped tool under 'allow'), returning its answer as is. undefined stays undefined, which the AI SDK reads as not applicable, so the tool's own needsApproval (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's denied and user-approval are never passed to app, 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 tools are treated as unmapped and denied unless unmapped is 'allow' (see fail-closed rules below).
  • context(context) returns plain JSON merged into subject.context under the policy's context, from the same per-call context subject reads. Only values the server derived belong there, never model output; a throw adds nothing and reports on('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.
  • wrap wraps each instance; build it with wrapPermDock (wrapping an instance).

Request lifecycle

  1. Before the model call, the middleware from capabilityMiddleware(context) builds a request-scoped PermDock from that context and removes tools whose permission has no grant for this subject. The model cannot plan with tools it may not use.
  2. The model emits a tool call. AI SDK invokes toolApproval with the tool name and arguments.
  3. The adapter validates the arguments against the resource schema when data is declared (boundary validation), loads the resource, and calls permdock.decide(permission, data).
  4. The Decision maps to the AI SDK vocabulary:
Decision outcometoolApproval resultNotes
grantedapprovedtool executes
denieddeniedreason carries denials and alternatives for the model
approval-requireduser-approvalUI or workflow asks a human; Decision.token attached
unmapped tool, validation error, thrown errordeniedfail closed
  1. On user-approval, the human's answer returns as an approval response. AI SDK then calls toolApproval again for the same toolCallId; the adapter recognises this re-check from the tool-approval-request in messages and answers denied unless a matching approval is recorded in store (the SDK treats any other answer as approved). The adapter re-runs decide and compares Decision.token (hash of permission key, resource id, subject, actor) with the token issued in step 4; a mismatch is denied. This is the problem experimental_toolApprovalSecret addresses by signing approval payloads; PermDock adds the check that the approved call is the same call.
  2. Every step emits on('decision') so audit logging and permdock/otel see 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 data resolver 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 permdockApproval on the context. An approval resumes one call; a replay is denied with approval-consumed, and a token issued for another call is ignored.
  • Tool coverage: in development, the adapter warns when tools passed to generateText include names missing from the tools map, because those calls will be denied at runtime.

How denials surface

  • denied results include a reason string built from the Decision: the failing role reasons and the alternatives list (permitted permissions on the same resource), so a model can choose a permitted action instead of retrying the denied one.
  • user-approval results carry Decision.token and 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 returns denied and logs the cause. This is the intentional contrast with @ai-sdk/policy-opa, where unrecognised decisions execute the tool (vercel/ai#19978).
  • capabilityMiddleware denials are silent by design: the tool is absent from the request. Set onDecision on 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 through on('decision'). A plan pre-flight is permdock.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.

Last updated on

On this page