Skip to content

Agent guards for OpenAI 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 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.

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

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 string
list, and body as a LOCAL string. Deny when recipient is not a member of
allowed_recipients. Deny when body contains a BANK_ACCOUNT or ROUTING_NUMBER
entity.
Add one test for a recipient on the allow list and one for an address outside
it, 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:

InputTypeExposureRequired
recipientStringSERVERYes
allowed_recipientsString listSERVERYes
bodyStringLOCALYes

Declare one detector, body_scan, for sensitive information over body, denying the BANK_ACCOUNT and ROUTING_NUMBER entity types.

Then declare two live rules:

RuleKindDecided by
external-recipientEXPRESSIONThe policy’s Rego
sensitive-bodyDETECTORbody_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.

In your project root, install the SDK:

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

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.

Keep identity, allowed recipients, and sensitive records on the server. The browser sends only the scenario name.

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 policy’s external-recipient rule 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 getClientRecord and receives test bank account and routing numbers. When the model puts that tool result in the email body, the policy’s sensitive-body rule denies sendEmail before 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.

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

Discussion