Adapter: Vercel AI SDK
Package:
@orpc-agent/ai-sdk. Peer:ai@^5 || ^6,@orpc-agent/core.
Converts a runtime's capabilities into AI SDK tools. Surface id: aiSdk. One capability exposed to aiSdk ⇢ one tool; the adapter is thin by contract (adapter model) — discovery, validation, policy, approval, and execution all happen in the runtime.
Supported ai versions
The peer range is ^5.0.0 || ^6.0.0, and there is one code path — no version branch, no compat shim. The three ai APIs the adapter touches are unchanged across the majors: tool() has the same overloads, jsonSchema() only widened its input (it accepts a promise now), and a Record<string, Tool> still satisfies ToolSet. jsonSchema() without an explicit validate performs no validation in either major, so the runtime stays the single validation authority on both.
CI runs the package's suite and its typecheck against both majors on every PR (the ai-sdk (ai v5) and ai-sdk (ai v6) legs); the v6 leg is pinned by an aliased ai-v6 devDependency rather than a floating install.
Two v6 changes are worth knowing even though they need no adapter change:
- v6 has its own tool approval (
needsApprovalon a tool,ToolApprovalRequestin the stream). The adapter never sets it — see host loops with their own approval UX for why the runtime has to be the only approval authority. A test asserts the field stays unset. toModelOutputchanged shape (it now receives{ toolCallId, input, output }). The adapter does not set it, so on both majors the envelope reaches the model through the default JSON encoding.
Provider spec versions differ (LanguageModelV2 in v5, LanguageModelV3 in v6), but that is between your model provider and ai — the adapter never touches a model. v6 still accepts a v2 model, which is why one scripted model drives both CI legs.
Usage
import { generateText, stepCountIs } from "ai";
import { toAISDKTools } from "@orpc-agent/ai-sdk";
// Inside your authenticated request handler — build tools PER REQUEST:
const tools = await toAISDKTools(runtime, {
actor: sessionActor(req), // authenticated identity, never model-derived (SI-3)
context: await createAppContext(req),
});
const result = await generateText({
model, // any provider — the adapter never touches it
system: SUPPORT_SYSTEM_PROMPT,
messages,
tools,
stopWhen: stepCountIs(8),
});Options (AISDKToolsOptions)
| Option | Required | Notes |
|---|---|---|
actor | yes | Bound into every call of this tool set |
context | yes | The app's oRPC context for this request |
scope | no | { tags?, ids? }; forwarded verbatim to runtime.describe — see below |
filter | no | (descriptor) => boolean; conversation-shaping only, not authorization (SI-2) |
toolNaming | no | Default .→_ (orders.refund → orders_refund); meta.adapters.aiSdk.toolName overrides per capability; collisions throw at build |
signal | no | Composed into every invocation (in addition to per-call abort from the loop) |
toAISDKTools awaits runtime.describe("aiSdk", { actor, context, scope }), so the returned set is already exposure- and discovery-policy-filtered for this actor. Build per request; caching a tool set across users leaks visibility decisions.
scope vs filter
Having both invites confusion, so state it plainly: scope decides what gets discovered; filter decides what survives discovery. Neither is authorization (SI-2) — a capability excluded by either remains invocable by an authorized actor, and only exposure or a policy makes one unreachable.
const tools = await toAISDKTools(runtime, {
actor, context,
scope: { tags: ["devices"] }, // billing capabilities' discovery policies never run
filter: (d) => d.risk === "low", // of what was discovered, keep the low-risk ones
});scope runs inside the runtime, before any discovery policy; filter runs here, on the descriptors that came back. On a large catalog re-composed per step, that is the difference between skipping the work and paying for it and dropping the result — see reference/runtime.md for the matching rules (tags is ANY; untagged capabilities match no tags scope).
What each generated tool contains
description— frommeta.description(plus the suffix" Requires approval."when the descriptor saysrequiresApproval— cheap, honest model guidance).inputSchema— the capability's input schema converted to JSON Schema (via@orpc-agent/core/schema) and wrapped with the AI SDK'sjsonSchema()helper. The adapter does not pre-validate;executeforwards raw arguments so the runtime remains the single validation authority (pipeline stage 5).execute(args, { abortSignal })— callsruntime.invoke(capabilityId, args, { actor, context, surface: "aiSdk", signal })and translates the envelope.
Result shape
Tool results are always a structured envelope (AISDKToolResult) — deterministic for the model, uniform across capabilities:
// completed
{ "status": "ok", "data": { /* redacted output */ } }
// approval-required
{ "status": "approval-required", "approvalId": "apr_9",
"message": "Awaiting approval: Refund of $649 exceeds $500." }
// failed / cancelled — exposeToModel errors
{ "status": "error", "error": { "code": "POLICY_DENIED",
"message": "Refunds of $5000 or more cannot be issued by agents.", "retryable": false } }
// failed — concealed errors (SI-9)
{ "status": "error", "error": { "code": "INTERNAL_ERROR",
"message": "The operation failed.", "retryable": false } }Design choices, stated:
- Return, don't throw. Throwing inside
executeproduces provider-dependent tool-error handling; a returned envelope keeps the model in the loop with typed, uniform feedback it can reason about (retry input validation, stop on denial, report approval state). INPUT_INVALIDincludeserror.details(issue paths) so the model can self-correct — the one case where details cross the boundary, because the model authored the data.approval-requiredis not an error: the model's correct behavior is to inform the user and stop, and the shape says so.
Approval flow in a chat loop
The tool result cannot wait for a human (tool calls should return promptly; approvals may take hours). Pattern:
model calls orders_refund → { status: "approval-required", approvalId }
model tells user; loop ends this turn
… human approves in your dashboard (runtime.approvals.decide) …
your app calls runtime.resume(approvalId, { context }) from the dashboard/worker
app posts the outcome into the conversation (or the user asks again and the
model re-invokes — new execution, new decision, consumed approvals don't re-fire)The inline approvals.handler mode exists for short-latency confirmation UIs (the human is present and the transport can hold the call open) — see guides/human-approval.md.
Host loops with their own approval UX
Agent frameworks that consume AI SDK tools (Mastra, and others) often ship their own tool-approval mechanism — a pre-execution "approve this tool call?" gate rendered by the host's stream. As of ai@6 the SDK ships one itself: a tool's needsApproval suspends the loop and emits a ToolApprovalRequest. When a governed runtime sits underneath, pick one authority, and it must be the runtime:
- Host-loop approval decides on the raw tool-call arguments, before
invoke: no canonical input hash (SI-5), no coordinator record, nocapability.approval_requested/approvedaudit events, no expiry, no self-approval check. For governed operations that is a shadow approval system your audit trail cannot see. - Running both gates double-prompts: the host asks, then the runtime's policy suspends the same call again.
So: do not enable the host's — or ai@6's — tool approval for governed tools. Let orpc-agent's policies decide, and render the approval-required envelope — it is a typed tool result (AISDKToolResult), so your UI can render an approve/deny card generically from the stream's tool-result parts (or from a pending-approvals fetch), then call your decide + resume endpoints. The Mastra task board example is this exact wiring, working.
For requester-confirmed gates where the human is present in the chat, the composition from the ADR-006 addendum fits streaming UIs: an inline approvals.handler holds the call open for human-confirmation types and returns undefined for everything else, deferring manager-type approvals to the coordinator flow unchanged.
Cancellation
The AI SDK's per-call abortSignal flows into runtime.invoke, composes with the capability timeout, and reaches the handler (SI-12). Aborting the chat request cancels in-flight capability calls; the audit trail shows capability.cancelled.
Testing
Don't test through a model. @orpc-agent/testing invokes with surface: "aiSdk" to assert exposure/policy behavior for this surface, and adapter conversion is covered by the conformance checklist (adapters/testing.md).
Related
Remote capability clients
toAISDKTools(client: CapabilityClient, options: RemoteAISDKToolsOptions) builds tools without a backend registry. Import CapabilityClient from @orpc-agent/core/client, or construct one with createHttpCapabilityClient from @orpc-agent/core/http. Options support scope, filter, signal, correlationId, and a required asynchronous invocationId({ capabilityId, input, toolCallId }) allocator. Persist the mapping before the allocator resolves; replay must recover the same ID independently of regenerated model call IDs.
The server fixes actor, application namespace and exposure surface. Resolved toolNames.aiSdk travels in the descriptor. Contextual descriptors are excluded from this unbound tool list. RemoteAISDKToolResult extends AISDKToolResult with { status: "outcome-unknown", invocationId, message }; reconcile that invocation before issuing another write. Approval results also carry the stable invocation ID. The existing local overload and its result shape are retained. Build both local and remote toolsets per authenticated request; do not share actor-filtered discovery across users.