Vercel AI SDK
Governing Vercel AI SDK agents with the explicit governed() wrapper — a live permit / deny / modify / defer demo.
The Vercel AI SDK's tool({ execute }) shape makes the enforcement point obvious: wrap the
execute body with raxit's governed(resource, gov, body) and every call goes through the
engine before the body runs — permit runs it, deny returns a [raxit BLOCKED] refusal
string, defer suspends for human approval, and modify runs the body on engine-redacted
args. This page walks a real payments-bot (mock Stripe-style tools) through all four outcomes,
backed by a demo that produced every transcript line below.
Install
npm install @raxitlabs/raxitRun it yourself
The walkthrough is deliberately no-LLM: the tools' execute functions are called directly in
a scripted sequence, so the decisions are deterministic and the run needs zero API keys. The
governance path exercised is exactly the one a generateText/streamText loop would hit.
The demo, step by step
Each step pairs the real code with the verbatim output from the captured run:
Every Vercel AI SDK tool body is wrapped with governed(resource, gov, body). The first argument is the policy resource the engine matches on — the body only runs when the engine permits.
charge_card: tool({
description: "Charge a card on file. amount_cents is the amount in the minor unit …",
inputSchema: z.object({
customer_id: z.string(),
amount_cents: z.number().int(),
currency: z.string().default("usd"),
}),
execute: governed("stripe.charge", gov, ({ customer_id, amount_cents, currency }) =>
`stripe.charge OK — created charge ch_stub for customer ${customer_id} …`, onDecision),
}),
create_payout: tool({
description: "Initiate a payout from the platform balance to an external bank account.",
inputSchema: z.object({ amount_cents: z.number().int(), destination: z.string() }),
execute: governed("stripe.payouts", gov, ({ amount_cents, destination }) =>
`stripe.payouts OK — payout of ${amount_cents} to ${destination} (stub — should never run)`,
onDecision),
}),How it works
The agent's tools are ordinary Vercel AI SDK tool() definitions; only the execute line
changes. The first argument to governed() is the policy resource the engine matches on:
import { tool } from "ai";
import { z } from "zod";
import { governed } from "raxit";
charge_card: tool({
description:
"Charge a card on file. amount_cents is the amount in the minor unit (4200 = $42.00 USD).",
inputSchema: z.object({
customer_id: z.string(),
amount_cents: z.number().int(),
currency: z.string().default("usd"),
}),
execute: governed("stripe.charge", gov, ({ customer_id, amount_cents, currency }) =>
`stripe.charge OK — created charge ch_stub for customer ${customer_id} amount=${amount_cents} ${currency} (stub)`),
}),(The repo example installs the local SDK under the package alias raxit, hence
from "raxit". With the published package, import from "@raxitlabs/raxit" instead.)
The whole policy is one file. default: block means anything not matched by a rule is denied —
that's what caught create_payout in the demo:
apiVersion: raxit.security/v1
agent: payments-bot
default: block
rules:
- effect: allow
resource: search_docs
- effect: allow
resource: stripe.charge
when: amount_cents < 50000
- effect: review # review == defer (needs human approval)
resource: stripe.charge
when: amount_cents >= 50000
- effect: allow
resource: stripe.customers.read
- resource: send_email # MODIFY: redact PII in the body before sending
modify: { redact: [body] }
- effect: review
resource: stripe.refund
- effect: block
resource: stripe.payoutsNote the two stripe.charge rules: the same resource is allowed under $500 and deferred to a
human at or above it — an arg-conditional split the demo exercises with a $10 charge
(permit, ran) while the refund_charge review rule shows the defer side.
Why explicit governed() and not autopatch here
raxit's TypeScript autopatch does cover the Vercel AI SDK zero-code — it hooks the SDK's
own registerTelemetry/executeTool hook, governing permit/deny/defer before the tool
body runs (tested against the real, package-installed SDK). But that hook has documented limits,
so this example uses the explicit wrapper instead:
Four documented bypasses of the telemetry hook
experimental_telemetry: { isEnabled: false } disables the dispatcher;
a caller-supplied experimental_telemetry: { integrations: [...] } replaces the global
registry; a direct tool.execute() call outside generateText/streamText/Agent never
passes through the dispatcher; and providerExecuted tools run server-side with nothing
local to intercept. MODIFY is also not supported there — the SDK captures input
before the wrapper runs, so args can't be rewritten in place. governed() is unaffected by
all of these and is the only way to get MODIFY on this framework.
Rule of thumb: autopatch is fine for permit/deny/defer visibility on a standard
generateText loop; wrap with governed() when you need MODIFY, or when the agent code (or a
prompt-injected model) could plausibly reach any of the four bypasses. See the coverage table
on Frameworks for the full point-by-point honesty notes.
What got denied and why
Both refusals in the demo carry a named reason code, straight from the transcript:
-
deny.default_or_forbid—create_payouthit the explicitblockrule onstripe.payouts(and would have been denied bydefault: blockanyway). The agent sees:[raxit BLOCKED] 'stripe.payouts' denied (reason: deny.default_or_forbid). The operation was not executed. Details: raxit explain 14befbda893c4ee197f732688952caed -
defer.policy—refund_chargematched thereviewrule onstripe.refund. The demo runs withRAXIT_DEFER_TIMEOUT=0so nobody is kept waiting in CI; unapproved resolves to a refusal, not an execution:[raxit PENDING] 'stripe.refund' needs human approval and was not approved (reason: defer.policy). The operation was not executed. Details: raxit explain 74df5ee1a60a400e850bc0c0f2d83355
In an interactive run the defer prints an approval token instead, and
raxit approvals approve <token> from another terminal resumes the call as a permit — the
same flow as the LangChain.js worked example. Either way the
refusal is a string returned to the model, not an exception that kills the agent loop, so the
model can read the reason and route around it.
Every raxit explain <call_id> above resolves against the run's audit file — the demo's
finale verified all 6 Decision Provenance Records signed and chain-intact
(ok=true signatures_verified=true signed_count=6 count=6).
Related
- Permit / defer / deny / modify / step-up — the five outcomes and how Cedar's binary decision becomes three-plus states
- Approvals — the human-in-the-loop flow behind
defer.policy - Audit & the DPR chain — what
audit.verifyactually checked - Fail closed — why an unreachable daemon means deny, not run

