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.

Use protect() for HTTP routes. Use the OpenAI Agents helpers on this page for authored tools and inbound screening. 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. Until @arcjet/guard/openai-agents/v0 is published, the import is on arcjet-js main, not on npm.

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. 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. Do not 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. Until @arcjet/guard/openai-agents/v0 is published, the skill source is integrate-arcjet-guard-openai-agents.

Screen user text before run() — there is no inbound hook. inputGuardrails / callModelInputFilter are not Guard.

Section titled “Screen user text before run() — there is no inbound hook. inputGuardrails / callModelInputFilter are not Guard.”

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, do not 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 is HITL, not a policy gate (same trap as hosted requireApproval).

Section titled “needsApproval is HITL, not a policy gate (same trap as hosted requireApproval).”

needsApproval pauses the run and returns interruptions for human-in-the-loop (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. Do not 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. The runner stringifies that object onto a function_call_result with status: "completed". Do not throw from execute to signal a denial: the SDK errorFunction would turn a throw into a model-visible string (or ToolCallError when outputSchema is set). 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 do not 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),
],
},
);

Deny inside tool({ execute }) — the only local side-effect you can stop. Hosted tools, handoffs, and Runner agent_tool_start are not deny points.

Section titled “Deny inside tool({ execute }) — the only local side-effect you can stop. Hosted tools, handoffs, and Runner agent_tool_start are not deny points.”

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 you already have on the app object you pass as run(..., { context }). Do not write runContext.conversationId. Do not 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, not policy.
  • There is no guardToolNode and no guardHooks.
  • Do not treat inputGuardrails, outputGuardrails, defineToolInputGuardrail / defineToolOutputGuardrail, or callModelInputFilter as Arcjet.
  • Do not deny from Runner agent_tool_start.
  • Do not call session.getSessionId() from this helper. Put the id you already have on context.
  • Do not read traceId for correlation. The SDK mints one when omitted.
  • Do not use this adapter with Realtime, Sandbox, hosted tools, MCP, agent.asTool(), or computer/shell.
  • Do not call createAgentContext inside an OpenAI Agents callback.
  • Do not also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Do not import @arcjet/guard/openai-agents. The path is @arcjet/guard/openai-agents/v0.