Vercel AI SDK agent guard
Vercel AI SDK agents call authored tool() handlers from
generateText, streamText, or ToolLoopAgent. Arcjet Guard sits between the
modelâs proposed arguments and execute, so a remote policy or SDK rule can
allow or deny the call before a side effect runs.
What is Arcjet?
Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.Use protect() on HTTP routes. Use
the helpers on this page for agent tools and other actions that have no HTTP
request.
You need an Arcjet account and an ARCJET_KEY. Launch
one client at module scope and reuse it.
Framework wrappers take an action string such as email.sent. That slug
selects the matching remote policy
and names the event in the Arcjet Console. Direct guard() calls use the field
name label for the same slug. Donât pass label to a wrapper such as
guardTool().
You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.
The JavaScript integration supports Vercel AI SDK v7 through the versioned
@arcjet/guard/vercel-ai/v7 export. This adapter is for AI SDK tool()
handlers. Donât use it to wrap Mastra createTool, Eve defineTool, LangGraph
tool(), OpenAI Agents tool(), Claude Agent SDK tool(), or official CrewAI
@tool â each of those has its own adapter.
Install
Section titled âInstallâInstall the Guard SDK and the AI SDK peers:
npm install @arcjet/guard ai @ai-sdk/provider-utilsImport helpers from @arcjet/guard/vercel-ai/v7. There is no unversioned alias.
@arcjet/guard/vercel-ai does not resolve. The version segment is the AI SDK
major. ai (>=7 <8) and @ai-sdk/provider-utils (>=5 <6) are optional
peers. The integration requires Node.js >=22.21.0 <23 || >=24.5.0.
Launch one client at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });Helpers
Section titled âHelpersâThe integration exposes the following surfaces:
guardTool()wraps an AI SDKtool({ execute }). OnDENYthe tool never runs. The model receives a structuredArcjetDenialResult({ arcjetDenied, reason, message, retryable, retryAfterSeconds? }). Donât throw. A throw becomes a generic tool error and drops the fields.aiToolsContext()maps anArcjetAgentContextontotoolsContextforgenerateText/streamText. Only tools branded byguardToolare included.createAgentContext()builds the context you pass toaiToolsContext. Pass a stablecorrelationIdwhen the app already has one. Omit it and the helper generates a universally unique lexicographically sortable identifier (ULID).guardAction()wraps an app-invoked function (not a model tool). OnDENYit throwsArcjetDeniedError. When Guard cannot be evaluated it throwsArcjetGuardUnavailableError.captureAction()records that something happened. It never blocks.securityMetadata()maps a small vocabulary (user,agent,workflow,dataClass,destination,reversibility,resource) onto wire keys.
The wrapped tool must have an execute function and cannot already declare a
contextSchema, because Arcjet uses that slot for agent context.
The Vercel AI integration skill ships with @arcjet/guard. After npm install,
copy or symlink it into your coding agentâs skills directory:
mkdir -p ~/.claude/skillscp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-agents ~/.claude/skills/Then ask the agent to add Arcjet Guard to this Vercel AI SDK project. In Claude
Code, run /integrate-arcjet-guard-agents. The skill source is
integrate-arcjet-guard-agents.
Create context for the run
Section titled âCreate context for the runâCreate one context at the HTTP route, job, or webhook that starts the run. Thread
it by hand into generateText and any guardAction call. Donât store it in
module state or AsyncLocalStorage.
import { createAgentContext, securityMetadata,} from "@arcjet/guard/vercel-ai/v7";
const context = createAgentContext({ // Reuse an ID the app already has so you can search the Sequence later. correlationId: conversationId, metadata: securityMetadata({ agent: "support-agent", workflow: "support-request", user: userId, }),});A caller-supplied correlationId must be 1-256 characters of printable ASCII.
Invalid values throw. Prefer a conversation ID, request ID, or ticket ID over a
generated value.
Wrap a model-invoked tool
Section titled âWrap a model-invoked toolâguardTool runs Guard before execute. On DENY the email provider never
runs and the model receives the denial object.
import { launchArcjet, policyInput, tokenBucket } from "@arcjet/guard";import { aiToolsContext, createAgentContext, guardTool,} from "@arcjet/guard/vercel-ai/v7";import { generateText, stepCountIs, tool } from "ai";import { z } from "zod";import { arcjet } from "./arcjet.js";
const emailLimit = tokenBucket({ bucket: "email", refillRate: 5, intervalSeconds: 60, maxTokens: 5,});
export async function runAgent( user: { id: string; allowedRecipients: string[] }, prompt: string,) { const sendEmail = guardTool( arcjet, tool({ description: "Send an email", inputSchema: z.object({ recipient: z.string().email(), body: z.string(), }), execute: ({ recipient, body }) => emailProvider.send({ to: recipient, body }), }), { // Selects the remote policy and names the Console event. action: "email.sent", actor: user.id, rules: () => [emailLimit({ key: user.id, requested: 1 })], inputs: ({ recipient, body }) => ({ recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList( user.allowedRecipients, ), body: policyInput.local.string(body), }), }, );
const tools = { sendEmail }; const context = createAgentContext({ correlationId: user.id, });
return generateText({ model: "openai/gpt-4o-mini", system: "If a tool call is denied by security policy, do not retry it. " + "Explain the denial to the user or try a different approach.", prompt, tools, toolsContext: aiToolsContext(context, tools), stopWhen: stepCountIs(3), });}The same tools and toolsContext pattern works with streamText and
ToolLoopAgent.
Options for guardTool
Section titled âOptions for guardToolâ| Option | Required | Description |
|---|---|---|
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. Slug: ASCII letters, digits, dash, underscore, and dot. Must start and end with a letter or digit. Case-sensitive; uppercase letters need @arcjet/guard 1.14.1 or later. |
rules | No | SDK rules, or a function of the parsed tool input. Omit or return [] to submit none. The Guard call still happens so a remote policy can apply. |
actor | No | Trusted identity, or a function of (input, context). Take it from authenticated application state, never from a model-produced argument. |
inputs | No | Named values built with policyInput.server.* or policyInput.local.*, or a function of (input, context). |
metadata | No | Nested JSON, or a function of the tool input. Merged over the context metadata. |
correlationId | No | Overrides the context correlation ID for this call. |
onGuardError | No | "deny" (default) or "allow". Controls what happens when Guard cannot be evaluated. A real DENY always blocks. |
onDeny | No | Reshape the payload the model sees for a real DENY. Does not run for outages. |
On a policy denial the model receives:
{ arcjetDenied: true, reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR message: "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.", retryable: true, retryAfterSeconds: 30,}You can import ArcjetDenialResult from @arcjet/guard/vercel-ai/v7.
Only rate-limit denials set retryable: true and include retryAfterSeconds.
Other reasons tell the model not to retry.
When Guard is unavailable and onGuardError is "deny", the model receives
reason: "ERROR", retryable: true, and retryAfterSeconds: 5.
Screen inbound text before generateText
Section titled âScreen inbound text before generateTextâThere is no guardInbound. Put prompt-injection and other inbound rules in the
application before generateText. Direct guard() fails open, so an ALLOW is
not proof the rules ran. Gate on decision.hasFailedOpen() when this call site
must fail closed.
import { detectPromptInjection } from "@arcjet/guard";import { createAgentContext } from "@arcjet/guard/vercel-ai/v7";import { arcjet } from "./arcjet.js";
const context = createAgentContext({ correlationId: conversationId });const inbound = detectPromptInjection();
const decision = await arcjet.guard({ label: "message.received", rules: [inbound(prompt)], correlationId: context.correlationId,});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("message blocked");}guardTool already defaults to fail closed. You donât need this extra check
on wrapped tools.
Guard an app-invoked action
Section titled âGuard an app-invoked actionâWhen your code performs a risky action the model did not call as a tool, wrap
that function with guardAction. It throws so application code can catch.
Donât share one handler with guardTool: a model-facing envelope must stay
inspectable, and a throw into the tool loop drops the fields.
import { ArcjetDeniedError, ArcjetGuardUnavailableError, captureAction, createAgentContext, guardAction, securityMetadata,} from "@arcjet/guard/vercel-ai/v7";import { tokenBucket } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
const submitLimit = tokenBucket({ bucket: "reviews", refillRate: 5, intervalSeconds: 60, maxTokens: 5,});
export async function submitGithubReview( context: ReturnType<typeof createAgentContext>, review: { repoId: string; owner: string; repo: string; prNumber: number; reviewText: string; },) { try { await guardAction( arcjet, context, { action: "review.submitted", rules: [submitLimit({ key: review.repoId, requested: 1 })], metadata: securityMetadata({ destination: "github", reversibility: "compensable", }), }, () => github.pulls.createReview({ owner: review.owner, repo: review.repo, pull_number: review.prNumber, body: review.reviewText, }), ); } catch (error) { if (error instanceof ArcjetDeniedError) { // A rule denied the call. Tell the user why. Don't retry. console.warn("denied:", error.decision.reason); } else if (error instanceof ArcjetGuardUnavailableError) { // Policy could not be evaluated. Alert on this separately from a DENY. console.warn("policy unavailable for:", error.action); } else { throw error; } }
captureAction(arcjet, context, { action: "notification.sent", metadata: securityMetadata({ destination: "slack" }), });}Use captureAction when you want a record and no decision. Omit rules on
guardTool when you still want a remote policy to be able to deny the call.
The policy that reads your inputs
Section titled âThe policy that reads your inputsâThe names in inputs are the contract. A policy reads them under
input.values, so a policy that declares recipient as a SERVER string and
allowed_recipients as a SERVER string list denies a recipient that isnât on
the list like this:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}A mistyped name produces a policy that silently never fires, so generate the guard call from the policy rather than transcribing it. Every policy page in the Console generates the call that sends its inputs.
For the names, kinds, and exposures a policy declares, see Policy contract. For more worked policies, see Policy examples.
Common patterns
Section titled âCommon patternsâRate limit a tool per user
Section titled âRate limit a tool per userâconst lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});
const lookupOrder = guardTool(arcjet, lookupOrderTool, { action: "order.looked-up", rules: () => [lookupLimit({ key: user.id, requested: 1 })],});Scan free-text arguments for sensitive information
Section titled âScan free-text arguments for sensitive informationâScan a note, reason, or body. An opaque orderId does not trip email, phone,
card, or IP detection, so do not pass it to localDetectSensitiveInfo.
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";import { rampart } from "@arcjet/sensitive-info-rampart";
const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"], backend: rampart(),});
const lookupOrder = guardTool(arcjet, lookupOrderTool, { action: "order.looked-up", rules: (input) => [ lookupLimit({ key: input.orderNumber, requested: 1 }), detectPii(input.note), ],});Map only the values a remote policy needs
Section titled âMap only the values a remote policy needsâBuild each input with policyInput. The SDK does not inspect the tool schema.
inputs: ({ recipient, body }) => ({ recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList(user.allowedRecipients), body: policyInput.local.string(body),}),For more information about input types and supported remote rules, see Policy contract.
Fail-closed default
Section titled âFail-closed defaultâguardTool and guardAction default to onGuardError: "deny". If Guard cannot
be evaluated, the wrapped function does not run.
Set onGuardError: "allow" only when executing without a complete security
decision is acceptable, such as a read-only lookup. Direct guard() still fails
open. For more information about fail-open versus fail-closed behavior, see
Availability and fail behavior.
Correlation
Section titled âCorrelationâPass the same ArcjetAgentContext to every helper in the run:
- Create it once with
createAgentContext. - Pass it to
aiToolsContext(context, tools). - Pass it to
guardAction/captureAction.
If you omit toolsContext, Guard still runs but the call is uncorrelated. The
first such call always warns.
What not to use
Section titled âWhat not to useâ- Donât pass
labeltoguardTool. The field isaction. - Donât declare a
contextSchemaon a tool you wrap. Arcjet uses that slot. - Donât wrap the same tool twice.
guardToolthrows if the tool already carries the Arcjet protection brand. - Donât throw from
guardToolto signal a denial. Return the payload so the model can inspect it. - Donât also wrap these tools with another framework adapter.
- Donât import
@arcjet/guard/vercel-ai. The path is@arcjet/guard/vercel-ai/v7. - Donât use this adapter with Mastra, Eve, LangGraph, OpenAI Agents, Claude Agent SDK, or official CrewAI tools.