Skip to content

Agent guards for TanStack AI

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 runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.

This guide shows you how to guard an agent tool in your project.

In the Arcjet Console, create an Agent Guard policy with the label email.sent. Add these inputs:

InputExposureType
recipientSERVERString
allowed_recipientsSERVERString list
bodyLOCALString

Then add:

  1. A string-list membership rule requiring recipient to be a member of allowed_recipients.
  2. A sensitive information rule on body that denies BANK_ACCOUNT and ROUTING_NUMBER.

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 running 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. See Testing and reference for the other policy statuses.

Framework wrappers take action for the same slug you configured as the policy label. Direct guard() calls still use the field name label.

In your project root, install the SDK:

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.

.env.local
ARCJET_KEY=ajkey_yourkey
ARCJET_ENV=development

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.

Create one client and pass guardMiddleware first on chat({ middleware }). There is no guardTool:

agent.ts
import { launchArcjet, localDetectSensitiveInfo } from "@arcjet/guard";
import { rampart } from "@arcjet/sensitive-info-rampart";
import { guardMiddleware } from "@arcjet/guard/tanstack-ai/v0";
import { chat, toolDefinition } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { z } from "zod";
// Placeholder for your mail transport.
const emailProvider = {
send: async (_: { to: string; body: string }) => ({ ok: true }),
};
// Rampart detects bank account and routing numbers on this machine. The
// rule needs its own reference to it, so share one instance: entity types
// outside the default set throw unless the rule has a backend.
const sensitiveInfoBackend = rampart();
// Create one Arcjet client and reuse it across agent runs.
const arcjet = launchArcjet({
key: process.env.ARCJET_KEY!,
sensitiveInfoBackend,
});
const detectPii = localDetectSensitiveInfo({
deny: ["BANK_ACCOUNT", "ROUTING_NUMBER"],
backend: sensitiveInfoBackend,
});
const sendEmailInput = z.object({
recipient: z.string(),
body: z.string(),
});
// Without a role the model asks a clarifying question, or masks the
// account numbers itself, instead of calling send_email with them. Either
// way the guard never gets a decision to make. The last two sentences make
// the sample deterministic; a real prompt can't be relied on for that,
// which is the reason to guard the tool.
const SYSTEM_PROMPT =
"You are a support desk assistant. Use get_client_record when the " +
"request needs account details. Use send_email 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.";
export async function runEmailAgent(
user: {
id: string;
allowedRecipients: string[];
record: {
name: string;
bankAccount: string;
routingNumber: string;
};
},
prompt: string,
) {
const getClientRecord = toolDefinition({
name: "get_client_record",
description: "Get the account details on file for the current customer",
inputSchema: z.object({}),
}).server(() => user.record);
const sendEmail = toolDefinition({
name: "send_email",
description: "Send an email",
inputSchema: sendEmailInput,
}).server(({ recipient, body }) =>
emailProvider.send({ to: recipient, body }),
);
const appContext = { sessionId: user.id };
// This adapter accepts action and rules. It doesn't accept
// inputs. There is no guardTool.
return chat({
adapter: openaiText("gpt-4o-mini"),
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: prompt }],
tools: [getClientRecord, sendEmail],
context: appContext,
stream: false,
middleware: [
guardMiddleware(arcjet, {
sessionId: user.id,
action: ({ toolName }) =>
toolName === "send_email" ? "email.sent" : "tool.invoked",
rules: ({ toolName, input }) => {
if (toolName !== "send_email") {
return [];
}
const { body } = sendEmailInput.parse(input);
return [detectPii(body)];
},
}),
],
});
}

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:

app/api/agent/route.ts
import { runEmailAgent } from "./agent.js";
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 output = await runEmailAgent(user, scenarios[scenario]);
return Response.json({ output });
}

Add a minimal page that calls the endpoint and displays the agent’s response. Serve it however your framework serves static files:

public/index.html
<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 sendEmail reaches the email provider.
  • Blocked recipient: The model calls the same tool with an external address, and the remote membership rule denies the call before the provider runs. The model receives the denial result and explains it. This scenario needs an adapter that maps inputs; on the others the send goes through, because the recipient never reaches the policy.
  • Sensitive information: The recipient is allowed, but the agent first calls getClientRecord and receives test bank account and routing numbers. When the model puts that tool result in the email body, the check denies sendEmail before the data leaves the application – through the remote policy’s body rule where the adapter maps inputs, and through the SDK rule where it doesn’t.

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.

Need help with anything? Email support@arcjet.com to get support from our engineering team.

Discussion