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
Section titled “Install”Install the Guard SDK and the OpenAI Agents SDK:
npm install @arcjet/guard @openai/agentsImport 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! });Helpers
Section titled “Helpers”The integration exposes two surfaces:
guardTool()wraps aFunctionToolfromtool({ execute }). Aftertool()the authoredexecuteis closed over. The runner callsinvoke, so this helper wrapsinvoke. OnDENYthe originalinvokenever runs, soexecutenever runs a side effect. It returns a plainArcjetDenialResult({ arcjetDenied, reason, message, retryable, retryAfterSeconds? }). It does not throw. The runner stringifies that object onto afunction_call_resultwithstatus: "completed". The denial rides in the payload (arcjetDenied: true), not the envelope. Because the runner treats that return as the tool’s output,timeoutMsraces the guard round trip as well asexecute, andoutputGuardrails/customDataExtractorreceive the denial object. KeeptimeoutMswide enough for a guard call.guardToolwarns ifinvokeis 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 onrunContext.context:correlationId, thensessionId, thenconversationId, thengroupId. Then it reads envelope copies (conversationId,groupId, already-resolvedsessionId). It also accepts a bare app object ({ sessionId }) or{ context: appContext, conversationId }. It never mints an id. It never readstraceId. It never callssession.getSessionId(). It never callscreateAgentContext.
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:
mkdir -p ~/.claude/skillscp -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().
Correlation
Section titled “Correlation”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 });What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection beforerun(). - There is no
guardApproval.needsApprovaland hostedrequireApprovalare human HITL, not policy. - There is no
guardToolNodeand noguardHooks. - Do not treat
inputGuardrails,outputGuardrails,defineToolInputGuardrail/defineToolOutputGuardrail, orcallModelInputFilteras Arcjet. - Do not deny from Runner
agent_tool_start. - Do not call
session.getSessionId()from this helper. Put the id you already have oncontext. - Do not read
traceIdfor 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
createAgentContextinside 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.