# AI SDK

Source: https://permdock.com/docs/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](/docs/concepts/decisions) 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 [#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](https://ai-sdk.dev/docs/agents/tool-approvals), [deprecation commit](https://github.com/vercel/ai/commit/04585590ff9e93f6823ad550d59a69dc4039cdbf)). Vercel's reference policy adapter, `@ai-sdk/policy-opa` ([Policy-Based Tool Approvals](https://ai-sdk.dev/docs/agents/policy-tool-approvals)), evaluates Rego and includes `opaCapabilityMiddleware` to trim the tool list; it also fails open when a decision is unrecognised ([vercel/ai#19978](https://github.com/vercel/ai/issues/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 [#api]

```ts
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:

```ts
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](/docs/guides/extending#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](/docs/guides/extending#wrapping-an-instance)).

## Request lifecycle [#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 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 |

5. 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.
6. 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 [#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 [#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](https://github.com/vercel/ai/issues/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 [#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 [#related-standards]

* [Approvals](/docs/security/approvals): `approval: 'human'`, replay-safe `token`, surfaces per runtime.
* [Delegation](/docs/security/delegation): the `actor` half of the subject for agent runs.
* [OWASP Agentic Top 10](/docs/security/owasp-agentic): ASI02 Tool Misuse mitigations via per-tool permissions and argument validation.
* [MCP adapter](/docs/adapters/mcp) and [Claude Agent adapter](/docs/adapters/claude-agent): the same Decision mapped to other runtimes.
