guardTool, createAgentContext, and aiToolsContext on generateText. Use guardAction for app-invoked work and captureAction for records. The wrapped tool must have execute and can't already declare contextSchema. On deny the model gets an ArcjetDenialResult.How do I secure a Vercel AI SDK agent?
Wrap tool() with guardTool, create a run context with createAgentContext, and pass aiToolsContext on generateText. The wrapped tool must have execute and can't already declare contextSchema. On deny the model gets an ArcjetDenialResult.
This is the security how-to for the Vercel AI SDK. Prompt injection protection for LangChain, LlamaIndex, and Vercel AI SDK only places detectors on the route and the retriever. A clean inbound score isn't authorization for sendEmail.
For more information about the combined LangChain and Vercel integration table, see Which runtime security tools integrate with LangChain or AutoGPT?. For more information about the Python action gate, see How do I secure a LangChain Python agent?. The product map is framework integrations.
What do I install and import?
Install @arcjet/guard with the AI SDK, then import the helpers from the versioned path:
npm install @arcjet/guard ai @ai-sdk/provider-utilsImport from @arcjet/guard/vercel-ai/v7. ai and @ai-sdk/provider-utils are optional peers that only this path needs. The version segment is deliberate: it stops a new AI SDK major from silently changing this API. An unversioned @arcjet/guard/vercel-ai import throws ERR_PACKAGE_PATH_NOT_EXPORTED rather than resolving to a guess.
Which helper do I use for each call?
Three helpers cover three different callers, and all of them attach the same correlation id so one run reads as a single Sequence:
guardTool()for model-invoked work, where the LLM decided to call the tool.guardAction()for app-invoked work, where your own code performs a risky action.captureAction()for observe-only records, where you want the event and no decision.
guardTool is the one that matters for a tool the model chose. guardAction is what you reach for when the risky step happens in your route handler after the agent returns, and it throws instead of returning a result: ArcjetDeniedError when a rule denied the call, and ArcjetGuardUnavailableError when the policy couldn't be evaluated. Those are separate types on purpose, so an outage can page someone without every ordinary denial doing the same. ArcjetGuardUnavailableError carries cause when the guard call threw and decision when a decision failed open.
Calling guardAction inside a tool's execute is also supported and keeps the control flow visible, but you have to thread the context in by hand. guardTool extracts it through the injected contextSchema.
Omitting rules on guardTool still makes the guard call, which keeps the decision correlatable and reachable by policy configured outside the code, but it costs a round trip. Returning [] behaves the same way. Use captureAction when you want the record and no decision: it never invokes the guard, never throws, and adds no outcome metadata, because there was no guarded execution to report on.
What must a wrapped tool declare?
The wrapped tool must have an execute function. guardTool sits between generated arguments and that function. A tool with no execute has nothing to wrap.
The wrapped tool can't already declare contextSchema. Arcjet uses that slot for agent context. guardTool throws if the tool already owns it.
action is the one required field on the policy. rules, actor, inputs, metadata, correlationId, onGuardError, and onDeny are all optional, and correlationId here overrides the context's.
import { launchArcjet, policyInput } from "@arcjet/guard";import { aiToolsContext, createAgentContext, guardTool,} from "@arcjet/guard/vercel-ai/v7";import { generateText, tool } from "ai";import { z } from "zod";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
export async function runAgent(user: { id: string }, prompt: string) { const sendEmail = guardTool( arcjet, tool({ description: "Send an email", inputSchema: z.object({ to: z.string(), body: z.string() }), execute: ({ to, body }) => emailProvider.send({ to, body }), }), { action: "email.sent", actor: user.id, inputs: ({ to, body }) => ({ recipient: policyInput.server.string(to), body: policyInput.local.string(body), }), }, );
const tools = { sendEmail }; const context = createAgentContext();
return generateText({ model, prompt, tools, toolsContext: aiToolsContext(context, tools), });}Derive actor from an authenticated server-side identity, never from a tool argument. A policy can be conditioned on the actor, so a model-produced actor lets an attacker who controls the prompt escape their own policy scope. The resolver form receives the parsed input and the agent context for exactly this reason: read the identity off the context, not off the model's arguments. policyInput.server.string() and policyInput.local.string() are how you mark which values the remote policy may see and which stay local.
createAgentContext() builds the run context that the wrapper injects. Pass a correlationId that the app already has, such as a request, job, ticket, or review id, and omit it to get a generated ULID. Correlation ids are 1-256 characters of printable ASCII, and an invalid value throws when you create the context rather than failing later. The context is a plain JSON-serializable object, so thread it hand to hand through queue payloads and workflow inputs. Don't stash it in module state or AsyncLocalStorage.
Forgetting toolsContext on generateText still compiles and leaves every check uncorrelated. The compiler can't catch it: the injected context type includes undefined, which makes the option optional. The first uncorrelated call warns, and later ones are silent unless you set ARCJET_LOG_LEVEL, so run the agent once with ARCJET_LOG_LEVEL=warn and confirm correlation before shipping. A tool called without toolsContext still executes; the wrapper has no run to join.
This works the same way with streamText, ToolLoopAgent, and a Vercel Workflow, because the wrapper only changes the tool's own behavior. Don't call createAgentContext inside another framework's callback. OpenAI Agents and LangGraph have their own context helpers. Don't also wrap those tools with @arcjet/guard/vercel-ai/v7.
What does the model see on deny?
A Vercel AI SDK policy denial stops execute. The model receives an ArcjetDenialResult carrying the deciding rule's own reason, such as RATE_LIMIT or PROMPT_INJECTION. The wrapper doesn't throw out of execute. Reshape that result with onDeny when the model needs different wording, but note that onDeny fires only for a real DENY: an unavailable guard takes a separate path and returns the fixed error result.
Only rate-limit denials are retryable. A tokenBucket deny reports reason: "RATE_LIMIT", retryable: true, and a computed retryAfterSeconds. Every other reason reports retryable: false and no backoff hint, which is the signal the model needs to stop trying.
onGuardError defaults to "deny", the opposite of a direct guard() call. If Guard can't be evaluated, the tool doesn't run and the model gets reason: "ERROR" with retryable: true and a fixed retryAfterSeconds: 5. Set "allow" only when executing without a complete decision is acceptable, which is a reasonable trade for a read-only lookup and a bad one for a send.
Tell the model what a denial means. Adding a line such as "if a tool call is denied by security policy, don't retry it; explain the denial or try a different approach" to the system prompt is what stops a denied sendEmail becoming a retry loop.
protect() is the HTTP check on a route. Don't mint a fake Request to reuse protect() inside execute. guardTool is the check that has no request object.
How do I name actions and add metadata?
An action label is the policy's stable name, not free text. Write it as resource.verb in the past tense, and the server validates it as a slug: lowercase letters, digits, dashes, and dots only. Use order.looked-up, because order.looked_up and Order.LookedUp are rejected.
securityMetadata() maps a fixed vocabulary onto the wire keys: user (whose authority, as an opaque id), agent (which automated actor), workflow, dataClass (public, internal, confidential, or regulated), destination (github, slack, internal), reversibility (reversible, compensable, or irreversible), and resource. Those fields are what let you ask later which irreversible actions an agent took against a regulated resource.
Metadata never changes a decision and is excluded from fingerprinting, so nothing you put there can fail a call. It is also untrusted and isn't redacted, so keep secrets and PII out of it. The server drops keys that exceed 128 top-level keys, 4 KiB per serialized value, or 10 levels of nesting, and reports each drop on decision.warnings. Capture is fire-and-forget and batched, so events can lag their decisions by a few seconds; a dropped event is diagnosed, never thrown.
How is this different from placing detectors?
The live prompt-injection page puts detectPromptInjection on a Next.js route with protect(). That screen asks whether this chat box can call the provider. It doesn't see sendEmail two steps later.
This page is the action gate. A well-formed { to, body } isn't a jailbreak. Screen the route. Then wrap the tool. Runtime security for LLM applications already splits those jobs.
Don't also wrap OpenAI Agents or LangGraph tools with @arcjet/guard/vercel-ai/v7. Those hosts have their own adapters. For more information about the Vercel AI SDK wrapper in a broader table, see Which runtime security tools integrate with LangChain or AutoGPT?. This page is the how-to.
How is the Vercel AI SDK gate different from LangChain Python?
The Vercel AI SDK wraps tool() and passes toolsContext. LangChain Python wraps a BaseTool or sits on create_agent. Both insert a checkpoint between generated arguments and the side effect. LlamaIndex has no Guard adapter.
A refund that still sends after you "added prompt injection" is the usual miss. The detector ran on the chat box. The tool never saw a deny. Put guardTool on sendEmail. Then keep the inbound screen on the route. For more information about the stack map, see agent framework security.
To confirm the wiring rather than assume it: run the app's typecheck, exercise the agent once with ARCJET_LOG_LEVEL=warn, check that the run's decisions and capture events share the correlation id you expect, then trip a rate limit deliberately and watch the model take the denial without looping.
Frequently asked questions
How do I secure a Vercel AI SDK agent?
Wrap tool() with guardTool, create a run context with createAgentContext, and pass aiToolsContext on generateText. The wrapped tool must have execute and can't already declare contextSchema. On deny the model gets an ArcjetDenialResult.
How is the Vercel AI SDK gate different from LangChain Python?
The Vercel AI SDK wraps tool() and passes toolsContext. LangChain Python wraps a BaseTool or sits on create_agent. The combined integration table is on Which runtime security tools integrate with LangChain or AutoGPT. The live prompt-injection page only places detectors.
Why can't the wrapped tool declare contextSchema?
Arcjet uses that slot for agent context. guardTool throws if the tool already owns contextSchema. The tool must also have execute; a tool with none has nothing to wrap.
What happens if I forget toolsContext on generateText?
The call still compiles and leaves every check uncorrelated, because the injected context type includes undefined. The first uncorrelated call warns; run once with ARCJET_LOG_LEVEL=warn and confirm correlation before shipping.
When do I use guardAction or captureAction instead?
guardTool is for model-invoked tools. guardAction is for risky actions your own code performs, and it throws ArcjetDeniedError on a deny or ArcjetGuardUnavailableError when the policy couldn't be evaluated. captureAction records an event and makes no decision.
Which denials are retryable?
Only rate-limit denials. A tokenBucket deny reports reason RATE_LIMIT, retryable: true, and a computed retryAfterSeconds. Other reasons report retryable: false. An unavailable guard reports reason ERROR with a fixed retryAfterSeconds: 5.
How do I name an action label?
Write resource.verb in the past tense. action is the only required field on the policy. The server validates it as a slug: lowercase letters, digits, dashes, and dots only. Use order.looked-up; order.looked_up is rejected. Metadata never changes a decision and isn't redacted, so keep PII out of it.
Can I set actor from a tool argument?
No. Derive actor from an authenticated server-side identity. A policy can be conditioned on the actor, so an actor taken from model-produced tool input lets whoever controls the prompt escape their own policy scope. The resolver form receives the agent context for that reason.
Does a prompt-injection detector on the route replace this?
No. The detector page places protect() on the chat route. That screen doesn't see sendEmail two steps later. This page is the action gate.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.