Agent guards for Claude Managed Agents
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.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”In the Arcjet Console, create an Agent Guard
policy with the label email.sent. Add these inputs:
| Input | Exposure | Type |
|---|---|---|
recipient | SERVER | String |
allowed_recipients | SERVER | String list |
body | LOCAL | String |
Then add:
- A string-list membership rule requiring
recipientto be a member ofallowed_recipients. - A sensitive information rule on
bodythat deniesBANK_ACCOUNTandROUTING_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.
2. Install Arcjet
Section titled “2. Install Arcjet”In your project root, install the SDK:
pip install "arcjet[claude-managed-agents,sensitive-info-rampart]"Or with uv:
uv add "arcjet[claude-managed-agents,sensitive-info-rampart]"npm i @arcjet/guard @arcjet/sensitive-info-rampart @anthropic-ai/sdkpnpm add @arcjet/guard @arcjet/sensitive-info-rampart @anthropic-ai/sdkyarn add @arcjet/guard @arcjet/sensitive-info-rampart @anthropic-ai/sdk3. 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=developmentANTHROPIC_API_KEY=sk-yourkeySet your environment variables:
# Export your Arcjet API key from https://console.arcjet.comARCJET_KEY=ajkey_yourkeyARCJET_ENV=developmentANTHROPIC_API_KEY=sk-yourkey4. 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.
Create one client, wrap send_email with guardCustomTool, and handle
agent.custom_tool_use in your session loop:
import Anthropic from "@anthropic-ai/sdk";import { launchArcjet, localDetectSensitiveInfo } from "@arcjet/guard";import { rampart } from "@arcjet/sensitive-info-rampart";import { claudeManagedAgentsContext, guardCustomTool, guardEvents,} from "@arcjet/guard/claude-managed-agents/v0";
// 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 client = new Anthropic();
export async function runEmailAgent( user: { conversationId: string; record: { name: string; bankAccount: string; routingNumber: string; }; }, sessionId: string, prompt: string,) { // Correlation is your own conversation id, never the Anthropic // session id. const context = claudeManagedAgentsContext({ correlationId: user.conversationId, });
const stream = await client.beta.sessions.events.stream(sessionId);
// Every tool result goes back on the same event, so build it in one place. const sendToolResult = ( customToolUseId: string, output: unknown, isError = false, ) => client.beta.sessions.events.send(sessionId, { events: [ { type: "user.custom_tool_result", custom_tool_use_id: customToolUseId, content: [{ type: "text", text: JSON.stringify(output) }], is_error: isError, }, ], });
// Anthropic runs the tool loop, so there is no PreToolUse hook. This // screens the prompt and sends it only if the guard allows. const inbound = await guardEvents( arcjet, { events: [ { type: "user.message", content: [{ type: "text", text: prompt }] }, ], inbound: { action: "message.received" }, context, }, (body) => client.beta.sessions.events.send(sessionId, body), ); if (!inbound.allowed) { return inbound.message; }
for await (const event of stream) { if (event.type === "agent.custom_tool_use") { // Dispatch on the tool name and treat anything else as an error. A // fallback here would hand an unknown name to whichever tool the // branch happens to end on, so name every tool you accept. if (event.name === "get_client_record") { await sendToolResult(event.id, user.record); } else if (event.name === "send_email") { // On deny, guardCustomTool sends the error result itself and // emailProvider is never called. const gated = await guardCustomTool( arcjet, { event, execute: async (input) => emailProvider.send({ to: String(input.recipient), body: String(input.body), }), send: (result) => client.beta.sessions.events.send(sessionId, { events: [result] }), }, { action: "email.sent", rules: (input) => [detectPii(String(input.body))], context, }, );
if (gated.allowed) { await sendToolResult(event.id, gated.output); } } else { await sendToolResult(event.id, `Unknown tool: ${event.name}`, true); } }
if (event.type === "session.status_idle") { return "Agent run completed."; } }}This adapter accepts inputs and actor. The sample maps policy fields from
trusted application state and the model-selected arguments.
Create one client, wrap send_email with guard_custom_tool, and handle
agent.custom_tool_use in your session loop:
import jsonimport os
from anthropic import AsyncAnthropicfrom arcjet.guard import launch_arcjet, local_input, server_inputfrom arcjet.guard.claude_managed_agents import ( guard_custom_tool, guard_events,)from arcjet_sensitive_info_rampart import rampart
# 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.arcjet = launch_arcjet( key=os.environ["ARCJET_KEY"], sensitive_info_backend=rampart(),)
# guard_events runs an inbound check before each user.message reaches the# session, so use the async client: the sync one can't be awaited here.client = AsyncAnthropic()
class EmailProvider: async def send(self, *, to: str, body: str) -> None: return None
# Placeholder for your mail transport.email_provider = EmailProvider()
def email_tools(user): async def get_client_record(_event) -> dict: return user.record
async def send_email(event) -> dict: arguments = event.input await email_provider.send( to=arguments["recipient"], body=arguments["body"] ) return {"status": "sent"}
# Pass run= for the hosted path. On DENY the handler sends the # denial as the tool result and send_email never runs. guarded_send_email = guard_custom_tool( guard=arcjet, run=send_email, # The action selects the remote policy you configured in step 1. action="email.sent", # Actor and the allow list come from trusted application state. actor=user.id, session_id=user.session_id, # Map only the values the remote policy needs. inputs=lambda arguments: { "recipient": server_input.string(arguments["recipient"]), "allowed_recipients": server_input.string_list( user.allowed_recipients ), "body": local_input.string(arguments["body"]), }, )
return get_client_record, guarded_send_email
async def run_email_agent(user, session_id: str, prompt: str): get_client_record, send_email = email_tools(user)
# Screen the inbound prompt, then send it with the wrapped send. send = guard_events( guard=arcjet, send=client.beta.sessions.events.send, action="message.received", actor=user.id, session_id=user.session_id, )
stream = await client.beta.sessions.events.stream(session_id) await send( session_id, events=[ { "type": "user.message", "content": [{"type": "text", "text": prompt}], } ], )
# Every tool result goes back on the same event, so build it in one place. async def send_tool_result(custom_tool_use_id, output, is_error=False): await send( session_id, events=[ { "type": "user.custom_tool_result", "custom_tool_use_id": custom_tool_use_id, "content": [{"type": "text", "text": json.dumps(output)}], "is_error": is_error, } ], )
async for event in stream: if event.type == "agent.custom_tool_use": # Dispatch on the tool name and treat anything else as an error. # Without this, an unrecognized name falls through to whichever # tool the branch ends on, which here would return the client # record. Name every tool you accept. if event.name == "get_client_record": record = await get_client_record(event) await send_tool_result(event.id, record) elif event.name == "send_email": # The wrapper sends the denial itself and returns None. result = await send_email( event, send=client.beta.sessions.events.send, session_id=session_id, ) if result is not None: await send_tool_result(event.id, result) else: await send_tool_result( event.id, f"Unknown tool: {event.name}", is_error=True ) if event.type == "session.status_idle": return "Agent run completed."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.
Arcjet correlates on your own conversation id, never on the Anthropic
session id, which addresses the session you created with sessions.create.
Steer the agent when you create the session, or it asks a clarifying
question instead of calling the tool and the guard never gets a decision to
make. Pass agent as agent_with_overrides with a system prompt telling
it to complete the request without follow-up questions, and quote retrieved
account details verbatim. Keep mcp_servers empty there too: an unguarded
server on the hosted agent can offer the model another way to send mail,
straight past the tool you guarded:
import { runEmailAgent } from "./agent.js";
// Keep identity and sensitive records on the server.const user = { // Your own conversation id. Arcjet correlates on this, not on the // Anthropic session id. conversationId: "conversation-123", 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 }); }
// The Anthropic session id from sessions.create. const sessionId = process.env.ANTHROPIC_SESSION_ID!; const output = await runEmailAgent(user, sessionId, scenarios[scenario]); return Response.json({ output: output ?? "Agent run completed." });}Expose a small server endpoint. The browser sends only the scenario name.
Arcjet correlates on your own conversation id, never on the Anthropic
session id, which addresses the session you created with sessions.create.
Steer the agent when you create the session, or it asks a clarifying
question instead of calling the tool and the guard never gets a decision to
make. Pass agent as agent_with_overrides with a system prompt telling
it to complete the request without follow-up questions, and quote retrieved
account details verbatim. Keep mcp_servers empty there too: an unguarded
server on the hosted agent can offer the model another way to send mail,
straight past the tool you guarded:
import osfrom typing import Literal
from fastapi import FastAPIfrom fastapi.staticfiles import StaticFilesfrom pydantic import BaseModel
from agent import run_email_agent
app = FastAPI()
# Keep identity, allowed recipients, and sensitive records on the server.class User: def __init__(self): self.id = "customer-123" # Your own conversation id. Arcjet correlates on this, not on the # Anthropic session id. self.session_id = "conversation-123" self.allowed_recipients = ["approved@example.com"] self.record = { "name": "Alex Morgan", "bank_account": "0123456789", "routing_number": "022000020", }
user = User()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." ),}
class AgentRequest(BaseModel): scenario: Literal["allowed", "blocked", "pii"]
@app.post("/api/agent")async def run_agent(request: AgentRequest): # The Anthropic session id from sessions.create. result = await run_email_agent( user, os.environ["ANTHROPIC_SESSION_ID"], scenarios[request.scenario], ) return {"output": str(result)}
# Serve the demo page in the next step from public/ at the site root. Mount# it after the route, so /api/agent still resolves.app.mount("/", StaticFiles(directory="public", html=True), name="public")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 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
getClientRecordand receives test bank account and routing numbers. When the model puts that tool result in the email body, the check deniessendEmailbefore the data leaves the application – through the remote policy’sbodyrule where the adapter mapsinputs, 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.
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.