Skip to content

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 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! });

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 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.

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.

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 });

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),
],
},
);

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.

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".

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 });
  • 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, no guardApproval, 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 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: 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.