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
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.
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. A throw hitserrorFunctionorToolCallErrorand drops the fields. 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. 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:
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. The skill source is
integrate-arcjet-guard-openai-agents.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardTool | Guard label and capture name. Use resource.verb in the past tense. |
rules | guardTool | SDK rules, or a function of the parsed tool input. Omit to submit none. |
metadata | guardTool | Nested JSON, or a function of the tool input. |
onGuardError | guardTool | "deny" (default) or "allow". |
onDeny | guardTool | Reshape 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.
Screen user text before run()
Section titled “Screen user text before run()”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 });Human approval is not a policy gate
Section titled “Human approval is not a policy gate”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), ], },);Deny authored tool({ execute }) handlers
Section titled “Deny authored tool({ execute }) handlers”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 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 });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 confirmation, not policy. - There is no
guardToolNodeand noguardHooks. - Don’t treat
inputGuardrails,outputGuardrails,defineToolInputGuardrail/defineToolOutputGuardrail, orcallModelInputFilteras Arcjet. - Don’t deny from Runner
agent_tool_start. - Don’t call
session.getSessionId()from this helper. Put the ID that you already have oncontext. - Don’t read
traceIdfor 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
createAgentContextinside an OpenAI Agents callback. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Don’t throw from
guardToolto signal a denial. A throw becomesToolCallErrorand drops the fields. - Don’t import
@arcjet/guard/openai-agents. The path is@arcjet/guard/openai-agents/v0.
Common patterns
Section titled “Common patterns”- Prompt injection before the run: Direct
guard()withdetectPromptInjection()(userText)and ahasFailedOpen()check. - Rate limit plus PII on a tool:
tokenBucketkeyed on a trusted ID, pluslocalDetectSensitiveInfo()on a free-textnote. - Correlate the run: Put
{ sessionId: conversationId }onrun(..., { context }). Don’t readtraceIdor callgetSessionId().