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 { 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 answers
  • operations 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. Build it with defineOperations (operations); the same map serves the other agent adapters.
  • 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 operations 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,
  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 operations 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).
  • 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.
  • otel takes (permdock) => withOtel(permdock, options) from permdock/otel and wraps each instance first (OpenTelemetry).
  • wrap wraps each instance after otel; build it with wrapPermDock (wrapping an instance).
  • stepUp turns a denial the user can lift into a user-approval pause, so the user can step up instead of the model reading a refusal. Two denials qualify. The first is insufficient-user-authentication, which asks for a fresh sign-in. The second is a denial that activating a role the user is eligible for would grant (elevated access). Without stepUp, both stay denied.
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 reason on the approval request; stepUp is there for an app that wraps toolApproval and renders its own control. at is the page the user signs in again at. PermDock adds the RFC 9470 acr_values and max_age the failing grants ask for, and leaves the URL out when at is absent or does not parse. For an activation, the app calls activate(permdock, { role, scope, id, reason }) and writes decision.elevation. The step-up status carries stepUp instead of token: there is no approval request in the store, so narrow with 'token' in status before reading token. With stepUp, needsApproval suspends a WorkflowAgent for the same denials, and capabilityMiddleware keeps the tools an eligible activation would open. deniedText does not rewrite the step-up reason, because the user reads it, not the model.

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
denied a step-up lifts, with stepUp setuser-approvalthe user signs in again or activates a role; stepUp
unmapped tool, validation error, thrown errordeniedfail closed
  1. On user-approval, the human's answer returns as an approval response. After a step-up, the re-check decides again on the subject runtimeContext now carries: granted answers approved and anything else denied, so the user's answer alone never runs the tool. 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.

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-approval status of toolApproval. Another function around toolApproval would be a second integration point that apps forget to install. The with* prefix is reserved for the Supabase middleware pipeline, withSubject and withOtel (naming). stepUp is the name permdock/mcp already 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 already eligible for lifts a denial that a simulated decision with that role's elevated membership grants. activate must 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 stay denied, because nothing the user does in the moment changes them. Purpose of use and break-glass are not offered: context.purpose comes 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 subject reads on the next call. The re-check decides again against the server-derived subject and approves only granted, the same rule an approval resume follows. An activation that needs approval goes through the approval flow of activate and its own token. The stepUp hint 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 stepUp on a denied OperationVerdict, and only permdock/ai-sdk turns it on. Claude Agent, Eve and OpenAI each pause through a different hook, and mapping them is a separate change.

Last updated on

On this page