OpenAI Agents agent guard
OpenAI Agents text Agent workflows call authored function-tool handlers from
the runner. Arcjet Guard sits at those boundaries so a remote policy can allow
or deny the action before a side effect runs. For more information about the
framework, see the
JavaScript and
Python OpenAI Agents docs.
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.
This page covers both adapters. The JavaScript adapter is
@arcjet/guard/openai-agents/v0, which wraps FunctionTool.invoke. The Python
adapter is arcjet.guard.openai_agents, which attaches a
tool_input_guardrails entry. Pick the tab for the language your agent runs in
and don’t mix the two.
Neither adapter covers Realtime, Sandbox, hosted tools, MCP, agents used as tools, or computer and shell tools.
Vercel AI SDK, LangChain, CrewAI, Eve, Mastra, LangGraph, Genkit, Strands Agents, and Claude wrappers are on Framework integrations.
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! });Install the Guard extra and the OpenAI Agents SDK:
pip install "arcjet[openai-agents]"arcjet[openai-agents] depends on openai-agents>=0.19.0,<1. CPython
3.10 or later.
Import helpers from arcjet.guard.openai_agents:
from arcjet.guard.openai_agents import ( guard_tool, openai_agents_context,)Launch one client at module scope:
from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)Use launch_arcjet_sync with Flask, Django, or other sync code.
Helpers
Section titled “Helpers”Pick the surface that matches what you hold when the effect runs.
Both adapters expose an authored-tool gate and a correlation-context helper. Neither has an inbound helper, an approval helper, a tool-node helper, or a hooks helper.
The OpenAI SDK’s own guardrail surfaces – agent input and output guardrails, and tool guardrail decorators you write yourself – are the SDK’s tripwires. They are not Arcjet.
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.
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.
| You have | Use | Needs | Blocks a call? |
|---|---|---|---|
| Any Python callable | guard_action / guard_action_sync | arcjet | Yes |
An authored FunctionTool | guard_tool | arcjet[openai-agents] | Yes |
| A session or conversation ID you already have | openai_agents_context | arcjet[openai-agents] | No |
guard_tool attaches the policy gate on
FunctionTool.tool_input_guardrails. On DENY, or when Guard cannot
be evaluated and on_guard_error is "deny", the helper calls
reject_content(...) with JSON of ArcjetDenialResult. The tool
handler does not run.
Don’t raise from the guardrail. A raise becomes a tripwire halt, or
default_tool_error_function swallows it. Denial is
reject_content(...) only.
There is no inbound helper. Screen user text with a direct guard()
call before Runner.run. There is no approval helper.
needs_approval is human-in-the-loop (HITL) confirmation, not
policy.
Don’t also wrap these tools with @arcjet/guard/openai-agents/v0 or
@arcjet/guard/vercel-ai/v7.
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.
guard_tool accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
rules | No | Bound SDK rule inputs, or a function of the parsed tool arguments. Omit to submit none. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
Inbound screening uses direct guard(), which takes label (not
action) and fails open.
openai_agents_context reads a caller-owned correlation_id, then
session_id, then conversation_id, then group_id from the
context you pass to Runner.run. It never mints an ID. It never
reads trace_id.
Screen user text before the run
Section titled “Screen user text before the run”There is no inbound hook and no inbound helper. Put prompt-injection and other inbound rules in the application before the runner starts.
A direct guard call fails open, so an ALLOW is not proof the rules ran. Gate
inbound on the failed-open check if this call site must fail closed. The
authored-tool gate already defaults to that. On DENY, don’t start the run.
inputGuardrails on the Agent or on tool(), outputGuardrails,
defineToolInputGuardrail / defineToolOutputGuardrail, and
callModelInputFilter are OpenAI SDK surfaces. They are not Arcjet.
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 });Agent input_guardrails, output_guardrails, and OpenAI
tool_input_guardrail / tool_output_guardrail decorators that you
write yourself are SDK surfaces. They are not Arcjet. The Arcjet gate
is the tool_input_guardrails entry that guard_tool attaches.
from agents import Runnerfrom arcjet.guard import DetectPromptInjection, launch_arcjetfrom arcjet.guard.openai_agents import openai_agents_context
arcjet = launch_arcjet(key=ARCJET_KEY)inbound = DetectPromptInjection()app_context = {"session_id": conversation_id}derived = openai_agents_context(app_context)
decision = await arcjet.guard( label="message.received", rules=[inbound(user_text)], correlation_id=derived.correlation_id,)if decision.conclusion == "DENY" or decision.has_failed_open(): raise RuntimeError("message blocked")
await Runner.run(agent, user_text, context=app_context)Gate authored function-tool handlers
Section titled “Gate authored function-tool handlers”The authored handler is the only local side effect either adapter can stop. On
DENY the handler does not run and the model receives JSON of
ArcjetDenialResult:
{ "arcjetDenied": true, "reason": "RATE_LIMIT", "message": "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.", "retryable": true, "retryAfterSeconds": 30}Hosted tools, handoffs, agents used as tools, MCP servers, and computer and shell tools do not go through the authored path, so they are not a deny point. Runner tool-start and tool-end events are observe-only.
Scan free-text arguments (a note, reason, or body). An opaque order number or tool-call ID doesn’t trip email, phone, card, or IP detection, so don’t pass it to the local sensitive information helper. That helper runs on a local ML model backend.
Pass wrapped tools on the agent’s tools list, then start the run.
guardTool wraps FunctionTool.invoke after tool({ execute }). That
closed-over execute is the only local side effect this adapter can stop.
It does not throw. A throw hits errorFunction or ToolCallError and drops
the fields. The runner stringifies the denial object onto a
function_call_result with status: "completed". timeoutMs races the guard
round trip, and outputGuardrails / customDataExtractor receive the denial
object.
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({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});
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), ], },);guard_tool attaches a tool_input_guardrails entry on a
FunctionTool from function_tool. On DENY the helper calls
reject_content(...) with JSON of ArcjetDenialResult.
Don’t raise from the guardrail. A raise becomes a tripwire halt, or
default_tool_error_function swallows it.
from agents import function_toolfrom arcjet.guard import ( LocalDetectSensitiveInfo, TokenBucket, launch_arcjet,)from arcjet.guard.openai_agents import guard_tool
arcjet = launch_arcjet(key=ARCJET_KEY)lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)detect_pii = LocalDetectSensitiveInfo( deny=["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],)
@function_tooldef lookup_order(order_id: str, note: str) -> dict: """Look up an order by ID.""" return {"order_id": order_id, "note": note, "status": "shipped"}
guarded_lookup = guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", rules=lambda arguments: [ lookup_limit(key="orders", requested=1), detect_pii(arguments["note"]), ],)Human approval is not a policy gate
Section titled “Human approval is not a policy gate”An approval flag on a function tool or an agent used as a tool pauses the run
for human-in-the-loop (HITL) confirmation. Hosted MCP approval is the same
class of control. Neither is a policy gate. That is the same trap as CrewAI
human_input, LangChain humanInTheLoopMiddleware, Mastra requireApproval,
Claude permission callbacks, LangGraph interrupt(), and Genkit
toolApproval.
There is no approval helper in either adapter. Don’t wrap an approval pause as Guard, and gate authored handlers you own with the authored-tool gate instead.
Fail-closed default
Section titled “Fail-closed default”The authored-tool gate fails closed. If Guard cannot be evaluated, the tool handler does not run and the model receives the denial payload. Fail open only when you can accept running the action without a complete security decision.
A DENY conclusion always blocks, whatever the fail behavior is set to. The
core guard call still fails open: it returns ALLOW and reports that it failed
open. The wrapper that sits around an effect fails closed.
For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.
guardTool defaults to onGuardError: "deny". The only other accepted value
is "allow".
guard_tool defaults to on_guard_error="deny", matching the
LangChain and CrewAI wrappers. If Guard cannot be evaluated, the
helper calls reject_content(...) with JSON of
ArcjetDenialResult and the tool handler does not run. The helper
rejects any value other than "allow". has_failed_open() returns
True when the core guard() call failed open.
Correlation
Section titled “Correlation”The runner has no conversation or session ID unless you put one on the context object you pass to it. Pass the ID that you already have and don’t mint one. Don’t read the SDK trace ID: the SDK mints a trace when you omit one.
Preference order is a caller-owned correlation ID, then a session ID, then a conversation ID, then a group ID. If nothing is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated ID.
Derive the ID from a session that the caller already has. A generated ID still joins this run’s events, but it builds a Sequence that nobody searches for.
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.
A bare app object ({ sessionId }) and { context: appContext, conversationId }
are both valid sources. traceId is never read.
const appContext = { sessionId: conversationId };
await run(agent, userText, { context: appContext });openai_agents_context reads a caller-owned correlation_id, then
session_id, then conversation_id, then group_id from that
context. trace_id is never read.
app_context = {"session_id": conversation_id}
await Runner.run(agent, user_text, context=app_context)What not to use
Section titled “What not to use”- There is no inbound helper. Screen prompt injection before the run.
- There is no approval helper. An approval pause is HITL confirmation, not policy.
- Don’t treat the SDK’s own agent or tool guardrail surfaces as Arcjet.
- Don’t deny from hosted tools, handoffs, agents used as tools, MCP, or computer and shell tools. Those paths have no authored gate.
- Don’t read the SDK trace ID for correlation. The SDK mints one when omitted.
- Don’t mint a session or conversation ID.
- Don’t mix the JavaScript and Python adapters on the same tool.
- Don’t use either adapter with Realtime or Sandbox.
- There is no
guardInbound, noguardApproval, noguardToolNode, and 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 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.
- Don’t raise from the guardrail. A raise becomes a tripwire halt, or
default_tool_error_functionswallows it. Denial isreject_content(...)only. - Don’t treat Agent
input_guardrails,output_guardrails, or a rawtool_input_guardrailyou wrote yourself as Arcjet. - Don’t wrap these tools with
@arcjet/guard/openai-agents/v0or@arcjet/guard/vercel-ai/v7.
Common patterns
Section titled “Common patterns”- Prompt injection before the run: a direct guard call with the prompt injection rule and a failed-open check.
- Rate limit plus PII on a tool: a token bucket keyed on a trusted identifier you own, plus the local sensitive information rule on a free-text note. Don’t key the bucket on free-text user input, and don’t pass an opaque order number to the sensitive information rule.
- Correlate the run: put the session ID you already have on the run context. Don’t read the trace ID and don’t mint an ID.
Related
Section titled “Related”- Framework integrations
- Vercel AI SDK agent guard
- LangGraph agent guard
- LangChain agent guard
- CrewAI agent guard
- Genkit agent guard
- Strands Agents agent guard
- Vercel Eve agent guard
- Mastra agent guard
- Claude Agent SDK agent guard
- Python Guard SDK reference
- Agent guards
- Python example: examples/fastapi-openai-agents-guard