Skip to content

OpenAI Agents agent guard

OpenAI Agents text Agent workflows call authored tool() handlers from run(). Arcjet Guard sits at those boundaries so a remote policy can allow or deny the action before a side effect 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.

Use protect() on HTTP routes. Use the helpers on this page for agent tools and other actions that have no HTTP request.

You need an Arcjet account and an ARCJET_KEY. Launch one client at module scope and reuse it.

Framework wrappers take an action string such as email.sent. That slug selects the matching remote policy and names the event in the Arcjet Console. Direct guard() calls use the field name label for the same slug. Don’t pass label to a wrapper such as guardTool().

You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.

Vercel AI SDK, Python LangChain, Eve, Mastra, LangGraph, and Claude wrappers are on Framework integrations.

This adapter is a text Agent plus run() plus authored tool(). It is not Realtime, Sandbox, hosted tools, MCP, agent.asTool(), or computer/shell.

Install the Guard SDK and the OpenAI Agents SDK:

Terminal window
npm install @arcjet/guard @openai/agents

Import helpers from the versioned path @arcjet/guard/openai-agents/v0. There is no unversioned alias. @arcjet/guard/openai-agents does not resolve. The SDK is pre-1.0, so the segment is v0. @openai/agents (>=0.17.0 <1) is an optional peer. The integration requires Node.js 22 or later.

Launch one client at module scope:

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

The integration exposes two surfaces:

  • guardTool() wraps a FunctionTool from tool({ execute }). After tool() the authored execute is closed over. The runner calls invoke, so this helper wraps invoke. On DENY the original invoke never runs, so execute never runs a side effect. It returns a plain ArcjetDenialResult ({ arcjetDenied, reason, message, retryable, retryAfterSeconds? }). It does not throw. A throw hits errorFunction or ToolCallError and drops the fields. The runner stringifies that object onto a function_call_result with status: "completed". The denial rides in the payload (arcjetDenied: true), not the envelope. Because the runner treats that return as the tool’s output, timeoutMs races the guard round trip as well as execute, and outputGuardrails / customDataExtractor receive the denial object. Keep timeoutMs wide enough for a guard call. guardTool warns if invoke is handed neither a string nor an object: the runner passes a JSON string, so another shape means no arguments were scanned.
  • openaiAgentsContext() reads a field you put on runContext.context: correlationId, then sessionId, then conversationId, then groupId. Then it reads envelope copies (conversationId, groupId, already-resolved sessionId). It also accepts a bare app object ({ sessionId }) or { context: appContext, conversationId }. It never mints an ID. It never reads traceId. It never calls session.getSessionId(). It never calls createAgentContext.

There is no guardInbound. There is no inbound hook. Screen user text with a direct guard() call before run(). There is no guardApproval, no guardToolNode, and no guardHooks.

OpenAI’s inputGuardrails, outputGuardrails, and defineToolInputGuardrail / defineToolOutputGuardrail are the SDK’s own tripwires (tripwireTriggered, rejectContent). They are not Arcjet.

Never call createAgentContext inside an OpenAI Agents callback. Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.

The OpenAI Agents integration skill ships with @arcjet/guard. After npm install, copy or symlink it into your coding agent’s skills directory:

Terminal window
mkdir -p ~/.claude/skills
cp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-openai-agents ~/.claude/skills/

Then ask the agent to add Arcjet Guard to this OpenAI Agents project. In Claude Code, run /integrate-arcjet-guard-openai-agents. The skill source is integrate-arcjet-guard-openai-agents.

OptionHelpersDescription
actionguardToolGuard label and capture name. Use resource.verb in the past tense.
rulesguardToolSDK rules, or a function of the parsed tool input. Omit to submit none.
metadataguardToolNested JSON, or a function of the tool input.
onGuardErrorguardTool"deny" (default) or "allow".
onDenyguardToolReshape the denial object the runner stringifies onto the tool result.

Inbound screening uses direct guard(), which takes label (not action) and fails open. See the following section.

There is no first-class inbound hook, so there is no guardInbound. Put prompt-injection (and other inbound rules) in the application before run(agent, input).

inputGuardrails on the Agent or on tool(), outputGuardrails, defineToolInputGuardrail / defineToolOutputGuardrail, and callModelInputFilter are OpenAI SDK surfaces. They are not Arcjet.

Direct client.guard({ label, rules, ...openaiAgentsContext(...) }) is the inbound pattern. Act on that decision. Direct guard() fails open, so an ALLOW is not proof the rules ran. Gate on decision.hasFailedOpen() if this call site must fail closed. guardTool already defaults to that.

On DENY, don’t call run().

import { detectPromptInjection } from "@arcjet/guard";
import { openaiAgentsContext } from "@arcjet/guard/openai-agents/v0";
import { run } from "@openai/agents";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const appContext = { sessionId: conversationId };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...openaiAgentsContext({ context: appContext, conversationId }),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}
await run(agent, userText, { context: appContext });

needsApproval pauses the run and returns interruptions for human-in-the-loop (HITL) confirmation (state.approve / state.reject). Hosted MCP requireApproval is the same class of control. Neither is a remote policy. Same trap as Mastra requireApproval, Claude canUseTool, and LangGraph interrupt(). There is no guardApproval. Don’t wrap them as Guard.

Use guardTool for authored tool({ execute }) handlers you own.

On DENY the original invoke never runs, so execute never runs a side effect. guardTool returns a plain ArcjetDenialResult ({ arcjetDenied: true, reason, message, retryable, retryAfterSeconds? }). It does not throw. A throw hits errorFunction or ToolCallError and drops the fields. The runner stringifies that object onto a function_call_result with status: "completed". Don’t throw from execute to signal a denial. timeoutMs races the guard round trip, and outputGuardrails / customDataExtractor receive the denial object. Scan free-text args (a note, reason, or body). An opaque orderNumber or tool-call ID will not trip email / phone / card / IP, so don’t pass it to localDetectSensitiveInfo. That helper runs on a local ML model backend.

import { tool } from "@openai/agents";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/openai-agents/v0";
import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// Factory then text. Same shape as `detectPromptInjection()(text)`.
const detectPii = localDetectSensitiveInfo();
export const lookupOrder = guardTool(
arcjet,
tool({
name: "lookup_order",
description: "Look up an order by number",
parameters: z.object({
orderNumber: z.string(),
note: z.string(),
}),
execute: async ({ orderNumber, note }) => ({
orderNumber,
note,
status: "shipped",
}),
}),
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: input.orderNumber, requested: 1 }),
detectPii(input.note),
],
},
);

guardTool wraps FunctionTool.invoke after tool({ execute }). That closed-over execute is the only local side effect this adapter can stop.

Hosted tools (webSearchTool, fileSearchTool, codeInterpreterTool, and the other hosted helpers), handoff, agent.asTool(), MCP servers (mcpServers), and computer/shell tools do not go through that authored invoke path. Runner agent_tool_start and agent_tool_end are observe-only. They are not a deny point. There is no guardToolNode and no guardHooks for those paths.

Pass wrapped tools on the Agent tools array, then call run().

RunContext has no conversation or session ID. Put the ID that you already have on the app object you pass as run(..., { context }). Don’t write runContext.conversationId. Don’t pass a Session and expect getSessionId() to run: openaiAgentsContext never calls it, and MemorySession mints a UUID when constructed without sessionId.

Preference order is context.correlationId, then context.sessionId, then context.conversationId, then context.groupId. Then envelope copies: conversationId, groupId, already-resolved sessionId. A bare app object ({ sessionId }) and { context: appContext, conversationId } are both valid sources. traceId is never read. If nothing is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated ID.

const appContext = { sessionId: conversationId };
await run(agent, userText, { context: appContext });
  • There is no guardInbound. Screen prompt injection before run().
  • There is no guardApproval. needsApproval and hosted requireApproval are human HITL confirmation, not policy.
  • There is no guardToolNode and no guardHooks.
  • Don’t treat inputGuardrails, outputGuardrails, defineToolInputGuardrail / defineToolOutputGuardrail, or callModelInputFilter as Arcjet.
  • Don’t deny from Runner agent_tool_start.
  • Don’t call session.getSessionId() from this helper. Put the ID that you already have on context.
  • Don’t read traceId for correlation. The SDK mints one when omitted.
  • Don’t use this adapter with Realtime, Sandbox, hosted tools, MCP, agent.asTool(), or computer/shell.
  • Don’t call createAgentContext inside an OpenAI Agents callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t throw from guardTool to signal a denial. A throw becomes ToolCallError and drops the fields.
  • Don’t import @arcjet/guard/openai-agents. The path is @arcjet/guard/openai-agents/v0.
  • Prompt injection before the run: Direct guard() with detectPromptInjection()(userText) and a hasFailedOpen() check.
  • Rate limit plus PII on a tool: tokenBucket keyed on a trusted ID, plus localDetectSensitiveInfo() on a free-text note.
  • Correlate the run: Put { sessionId: conversationId } on run(..., { context }). Don’t read traceId or call getSessionId().