Skip to content

Agent guards for LangChain

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.

Publish the policy before running the example.

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:

Install the Guard SDK with the LangChain and Rampart extras:

Terminal window
pip install "arcjet[langchain,sensitive-info-rampart]" langchain langchain-openai

Or with uv:

Terminal window
uv add "arcjet[langchain,sensitive-info-rampart]" langchain langchain-openai

Create a free Arcjet account and follow the instructions to add a site and get a key.

Set your environment variables:

.env
# Export your Arcjet API key from https://console.arcjet.com
ARCJET_KEY=ajkey_yourkey
ARCJET_ENV=development
OPENAI_API_KEY=sk-yourkey

Wrap the send-email tool so Arcjet evaluates the remote policy before the email provider runs.

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 send_email with guard_tool:

agent.py
import os
from arcjet.guard import launch_arcjet, local_input, server_input
from arcjet.guard.langchain import guard_tool
from arcjet_sensitive_info_rampart import rampart
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
# Create one Arcjet client and enable local bank-detail detection.
arcjet = launch_arcjet(
key=os.environ["ARCJET_KEY"],
sensitive_info_backend=rampart(),
)
@tool
async def send_email(recipient: str, body: str) -> str:
"""Send an email."""
await email_provider.send(to=recipient, body=body)
return "sent"
async def run_email_agent(user, prompt: str):
# This read-only tool returns the current customer's account
# data.
@tool
async def get_client_record() -> dict:
"""Get the account details on file for the current customer."""
return user.record
# guard_tool checks remote policy before send_email can run.
guarded_send_email = guard_tool(
guard=arcjet,
tool=send_email,
# 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=lambda arguments, _config: {
"recipient": server_input.string(arguments["recipient"]),
"allowed_recipients": server_input.string_list(
user.allowed_recipients
),
"body": local_input.string(arguments["body"]),
},
)
# The model can call either tool, but send_email always passes
# through Arcjet.
agent = create_agent(
ChatOpenAI(model="gpt-4o-mini"),
tools=[get_client_record, guarded_send_email],
system_prompt=(
"Use get_client_record when the user asks for account "
"details. Use send_email exactly once to complete the "
"request."
),
)
return await agent.ainvoke(
{"messages": [{"role": "user", "content": prompt}]}
)

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.py
from typing import Literal
from fastapi import FastAPI
from pydantic import BaseModel
from agent import run_email_agent
app = FastAPI()
class User:
def __init__(self):
self.id = "customer-123"
self.allowed_recipients = ["approved@example.com"]
self.record = {
"name": "Alex Morgan",
"bank_account": "0123456789",
"routing_number": "022000020",
}
# Keep identity, allowed recipients, and sensitive records on the
# server.
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):
result = await run_email_agent(user, scenarios[request.scenario])
return {"output": result["messages"][-1].content}

Add a minimal page that calls the endpoint and displays the agent’s response:

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 can reach the email provider.
  • Blocked recipient: The model calls the same tool with an external address. When the adapter maps inputs, the remote membership policy denies the call before the provider runs, and the model receives the denial result.
  • Sensitive information: The recipient is allowed, but the agent first calls getClientRecord and receives test bank account and routing numbers. When the model includes that tool result in the email body, local sensitive information policy blocks sendEmail before the data leaves the application.

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

Discussion