Agent guards for Vercel AI SDK
This example gives an AI agent two tools. getClientRecord returns account data
that includes personally identifiable information (PII). Arcjet guards
sendEmail, so the model can read the record but cannot send its sensitive
fields outside the application. Arcjet checks the model-selected recipient and
generated message body before the email provider 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.Quick start
Section titled “Quick start”This guide shows you how to guard an agent tool in your
1. Configure the policy
Section titled “1. Configure the policy”The policy decides what the guarded tool is allowed to do. Create it once, in the Arcjet Console, before you run the example.
You don’t have to write the conditions. Describe what the action refuses and
Arcjet writes them for you. Go to Policies, choose to guard an action in
your own application rather than a coding agent, enter email.sent as the
Guard label, and paste this description:
Deny when recipient is not in the allowed_recipients list.Deny when body contains a bank account number or a routing number.Arcjet opens a draft in the visual builder with three declared inputs, a
sensitive information detector over body, and two rules in dry run. Review
it, set both rules live, add a test asserting that an address on the allow list
fires no rule and one asserting that an address outside it is denied, then
publish. A live rule can’t publish without at least one stored test.
Have your coding agent configure it
Connect the Arcjet MCP server and give your agent this prompt. It reads and writes the same policies the Console does, so nothing about the result differs.
Create an Arcjet guard policy with the label email.sent.
Declare recipient as a SERVER string, allowed_recipients as a SERVER stringlist, and body as a LOCAL string. Deny when recipient is not a member ofallowed_recipients. Deny when body contains a BANK_ACCOUNT or ROUTING_NUMBERentity.
Add one test for a recipient on the allow list and one for an address outsideit, then publish. Show me the guard() call that sends these inputs.Ask it for the guard call as well as the policy. A policy does nothing until the application sends values under exactly the names it declares, and a mistyped name produces a policy that silently never fires.
Configure it by hand
Declare these inputs:
| Input | Type | Exposure | Required |
|---|---|---|---|
recipient | String | SERVER | Yes |
allowed_recipients | String list | SERVER | Yes |
body | String | LOCAL | Yes |
Declare one detector, body_scan, for sensitive information over body,
denying the BANK_ACCOUNT and ROUTING_NUMBER entity types.
Then declare two live rules:
| Rule | Kind | Decided by |
|---|---|---|
external-recipient | EXPRESSION | The policy’s Rego |
sensitive-body | DETECTOR | body_scan, directly |
external-recipient needs one condition. In the visual builder, add a
condition requiring recipient to be a member of allowed_recipients. The
builder generates this Rego:
package arcjet.guard
import rego.v1
deny contains "external-recipient" if { not input.values.recipient in input.values.allowed_recipients}sensitive-body needs no expression. A detector rule is decided by its
detector, which is what keeps the email body private: the SDK runs the
detection and Arcjet receives the verdict, never the text.
The examples configure the on-device Rampart backend, which detects these
entity types locally, so the body never leaves your application. The first
guarded call loads the Rampart model, so it takes noticeably longer than the
ones after it.
Publish the policy before you run the example. Until you do, the label matches
nothing and every guard call comes back NOT_CONFIGURED, which falls through
to the SDK rule decision rather than denying. For the other statuses, see
Testing and reference.
Framework wrappers take action for the same slug you configured as the policy
label. Direct guard() calls use the field name label.
2. Install Arcjet
Section titled “2. Install Arcjet”In your project root, install the SDK:
npm i @arcjet/guard @arcjet/sensitive-info-rampart ai @ai-sdk/provider-utils zodpnpm add @arcjet/guard @arcjet/sensitive-info-rampart ai @ai-sdk/provider-utils zodyarn add @arcjet/guard @arcjet/sensitive-info-rampart ai @ai-sdk/provider-utils zod3. Set your key
Section titled “3. Set your key”Create a free Arcjet account and follow the instructions to add a site and get a key.
Add your key to a .env.local file in your project root.
ARCJET_KEY=ajkey_yourkeyARCJET_ENV=development4. Wrap the tool
Section titled “4. Wrap the tool”Wrap the send-email tool so Arcjet evaluates the remote policy before the email provider runs.
Guarding one tool only helps if it is the only way to reach the capability. If the same agent session also exposes an unguarded path to sending mail – an MCP server it inherited, a built-in tool, a second tool you didn’t wrap – the model can take that path instead. Keep the session’s tool surface to what you wrapped.
This adapter accepts inputs and actor. The sample maps policy fields from
trusted application state and the model-selected arguments.
Create one client and wrap sendEmail with guardTool:
import { launchArcjet, policyInput } from "@arcjet/guard";import { rampart } from "@arcjet/sensitive-info-rampart";import { aiToolsContext, createAgentContext, guardTool,} from "@arcjet/guard/vercel-ai/v7";import { generateText, stepCountIs, tool } from "ai";import { z } from "zod";
// Placeholder for your mail transport.const emailProvider = { send: async (_: { to: string; body: string }) => ({ ok: true }),};
// Create one Arcjet client and reuse it across agent runs. Rampart// evaluates the policy's LOCAL inputs on this machine, so the email body// never leaves your application.const arcjet = launchArcjet({ key: process.env.ARCJET_KEY!, sensitiveInfoBackend: rampart(),});
export async function runEmailAgent( user: { id: string; allowedRecipients: string[]; record: { name: string; bankAccount: string; routingNumber: string; }; }, prompt: string,) { // This read-only tool gives the model the current customer's // account data. const getClientRecord = tool({ description: "Get the account details on file for the current customer", inputSchema: z.object({}), execute: () => user.record, });
// guardTool checks policy before the email provider can run. 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 }), }), { // The action selects the remote policy configured in step 1. action: "email.sent", // Actor and the allow list come from trusted application // state. actor: user.id, // Map only the values the remote policy needs. inputs: ({ recipient, body }) => ({ recipient: policyInput.server.string(recipient), allowed_recipients: policyInput.server.stringList( user.allowedRecipients, ), body: policyInput.local.string(body), }), }, );
const tools = { getClientRecord, sendEmail }; const context = createAgentContext();
// The model can call either tool, but sendEmail always passes // through Arcjet. return generateText({ model: "openai/gpt-4o-mini", system: "You are a support desk assistant. Use getClientRecord when the " + "request needs account details. Use sendEmail exactly once to " + "complete the request. Never ask a follow-up question. Quote " + "any account details you retrieve in the email body exactly " + "as returned, without masking or summarizing them.", prompt, tools, toolsContext: aiToolsContext(context, tools), stopWhen: stepCountIs(3), });}5. Try the policy
Section titled “5. Try the policy”Keep identity, allowed recipients, and sensitive records on the server. The browser sends only the scenario name.
Expose a small server endpoint. The browser sends only the scenario name:
import { runEmailAgent } from "./agent.js";
// Keep identity, allowed recipients, and sensitive records on the// server.const user = { id: "customer-123", allowedRecipients: ["approved@example.com"], record: { name: "Alex Morgan", bankAccount: "0123456789", routingNumber: "022000020", },};
const scenarios = { allowed: "Send the message 'Your report is ready' to approved@example.com.", blocked: "Send the message 'Your report is ready' to outside@example.net.", pii: "Email the account details you have on file to approved@example.com.",} as const;
export async function POST(request: Request) { const { scenario } = (await request.json()) as { scenario?: string; }; if ( scenario !== "allowed" && scenario !== "blocked" && scenario !== "pii" ) { return Response.json( { error: "Unknown scenario" }, { status: 400 }, ); }
const result = await runEmailAgent(user, scenarios[scenario]); return Response.json({ output: result.text });}Add a minimal page that calls the endpoint and displays the agent’s response. Serve it however your framework serves static files:
<h1>Agent Guard policy demo</h1><p>Test recipient and sensitive-information policies on the same email tool.</p>
<button data-scenario="allowed">Allowed recipient</button><button data-scenario="blocked">Blocked recipient</button><button data-scenario="pii">Sensitive information</button><pre id="output">Choose a scenario.</pre>
<script> // Send only the selected scenario name to the server. const output = document.querySelector("#output");
for (const button of document.querySelectorAll("button")) { button.addEventListener("click", async () => { output.textContent = "Running agent…"; const response = await fetch("/api/agent", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scenario: button.dataset.scenario }), }); const result = await response.json(); output.textContent = result.output ?? result.error; }); }</script>Each scenario demonstrates a different result from the same guarded tool:
- Allowed recipient: The recipient is on the allow list and the body has no
sensitive data, so
sendEmailreaches the email provider. - Blocked recipient: The model calls the same tool with an external
address, and the policy’s
external-recipientrule denies the call before the provider runs. The model receives the denial result and explains it. - Sensitive information: The recipient is allowed, but the agent first
calls
getClientRecordand receives test bank account and routing numbers. When the model puts that tool result in the email body, the policy’ssensitive-bodyrule deniessendEmailbefore the data leaves the application. The detector runs in the SDK, so the body is never sent to Arcjet.
Read the decision, not the absence of an email
Section titled “Read the decision, not the absence of an email”The samples give the agent a system prompt for a reason. Without one the
model asks a clarifying question, or masks the account numbers itself,
instead of calling sendEmail with them. Nothing is sent, no guard call is
made, and no decision is returned.
That is the outcome to watch for, because it looks like the guard worked. A reader with a broken policy, a wrong label, or a missing key sees “nothing was sent” and concludes the guard is enforcing. Check the decision in the Arcjet dashboard or in your logs. No decision means the model declined, not that the guard denied.
The same distinction is the reason to guard the tool at all. An unguarded agent often refuses the sensitive-information scenario on its own, because sending bank details over email looks wrong to the model. That refusal is judgement: it is non-deterministic, and a different prompt can talk the model out of it. The blocked-recipient scenario is the deterministic contrast, since nothing about an external address looks unsafe to the model.
What next?
Section titled “What next?”Get help
Section titled “Get help”Need help with anything? Email support@arcjet.com to get support from our engineering team.