Integrate Arcjet Guard into an OpenAI Agents app
@arcjet/guard's OpenAI Agents v0 namespace wraps the agent's existing
Arcjet client. It never talks to the Arcjet API itself. Two surfaces, one
decision rule:
- An authored tool (
tool({ execute })) →guardTool(). Aftertool()the object is aFunctionTool; the runner callsinvoke. DENY returns a structuredArcjetDenialResult. Do not throw. - Correlation →
openaiAgentsContext()reads a field the integrator put onrunContext.context(then documented copies:conversationId,groupId, already-resolvedsessionId). It never mints a new id. It never callssession.getSessionId().
This namespace is text Agent + run() / Runner. Not Realtime,
not Sandbox, not hosted tools, not computer / shell / apply_patch, not
MCP, not agent.asTool().
Screen inbound before run() (SDK inputGuardrails are not Arcjet)
There is no first-class inbound channel, so there is no guardInbound.
Put prompt-injection (and other inbound rules) in the application before
run(). SDK inputGuardrails / outputGuardrails /
defineToolInputGuardrail / defineToolOutputGuardrail are the SDK's
own tripwires, not this policy gate. Do not wrap them as Guard.
needsApproval is not a policy gate
needsApproval / requireApproval / onApproval is human-in-the-loop.
The run pauses; result.state.approve / reject. Same trap as Mastra
requireApproval, Claude canUseTool, LangGraph interrupt(), and
Google ADK requireConfirmation.
There is no guardApproval. Do not wrap them as Guard.
tool() execute is the deny point; hosted, MCP, and handoffs are not
The runner executes authored function tools in toolExecution.ts via
invoke. Hosted tools, handoffs, computer / shell / apply_patch, and
MCP (mcpServers → mcpToFunctionTool) skip that authored-execute
path. agent_tool_start / agent_tool_end are void observe-only
hooks; they are not a deny. There is no guardHooks and no
guardToolNode (there is no ToolNode).
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which tools are risky (external side effects, irreversible, spends
money, sends messages)? Those get
guardTool. Hosted / MCP / handoffs are out of v0 scope. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: none. Pass it via
metadataon the policy. Put the conversation / session id you already have onrun(..., { context: { sessionId } }). That id is the correlation id, not the user. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inbound screening beforerun(): failing closed there means the agent does not run for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.
The six things readers get wrong
- There is no
guardInbound. Screen prompt injection beforerun(). SDK input/output guardrails are not Arcjet. needsApprovalis not a policy gate. It is HITL. UseguardTool.- The import path is versioned and there is no alias.
@arcjet/guard/openai-agents/v0.@arcjet/guard/openai-agentsdoes not resolve. - Correlation is read, never minted. Do not call
createAgentContextinside a run callback — that generates a second id and splits the Sequence.RunContexthas no session / conversation id of its own. Put the id you already chose onrun(..., { context }). Do not callsession.getSessionId()from the helper:MemorySessionmints a UUID when constructed withoutsessionId. Do not usetraceId(the SDK mints one when omitted). - Do not double-wrap with
@arcjet/guard/vercel-ai/v7or@arcjet/guard/claude-managed-agents/v0.guardToolthrows if the tool already carries the Arcjet protection brand. - A denial from
guardToolis a structured object, not a throw. Throwing would hit the SDKerrorFunction(a generic string, orToolCallErrorwhenoutputSchema/errorFunction: null). The runner stringifies the object onto afunction_call_resultwithstatus: "completed"— the denial is in the payload (arcjetDenied: true). IfonDenythrows, the tool still does not run and the model still receives the default denial.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @openai/agents (optional peer,
needed for @arcjet/guard/openai-agents/v0). Always use the versioned
path: @arcjet/guard/openai-agents/v0 resolves;
@arcjet/guard/openai-agents throws ERR_PACKAGE_PATH_NOT_EXPORTED.
Zod is the OpenAI Agents peer, not ours — install zod only if the app
already uses it for tool({ parameters }).
npm install @arcjet/guard @openai/agents
If the agent has no guard client yet, launch one once at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
Step 2: Gate authored tools
import { tool } from "@openai/agents";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/openai-agents/v0";
import { tokenBucket, localDetectSensitiveInfo, policyInput } 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)`.
// Scan free-text args (a note, reason, body). An opaque `orderId` will
// not trip EMAIL / phone / card / IP, so do not pass it here.
const detectPii = localDetectSensitiveInfo();
export const lookupOrder = guardTool(
arcjet,
tool({
name: "lookup_order",
description: "Look up an order by ID",
parameters: z.object({
orderId: z.string(),
note: z.string(),
}),
execute: async ({ orderId, note }) => ({ orderId, note, status: "shipped" }),
}),
{
action: "order.looked-up",
actor: userId,
inputs: (input: { orderId: string; note: string }) => ({
orderId: policyInput.server.string(input.orderId),
}),
rules: (input: { orderId: string; note: string }) => [
lookupLimit({ key: input.orderId, requested: 1 }),
detectPii(input.note),
],
},
);
- Omit
rulesto submit none. The guard call still happens. - Optional
actorandinputs(static, or a resolver over this adapter's native call — parsed input plus trusted runtime/context) are forwarded on the guard call so a remote policy that declares those names can evaluate. Build each input withpolicyInput. - On DENY the closed-over
executenever runs. The model receives{ arcjetDenied: true, reason, message, retryable }as the tool result (stringified by the runner). - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable. - The runner treats the denial as the tool's output. If the tool sets
timeoutMs, that race now covers the guard round trip too, so leave headroom for it; if it setsoutputGuardrailsorcustomDataExtractor, those receive the denial object and must not assume the tool's own shape.
Step 3: Screen inbound before run
import { detectPromptInjection } from "@arcjet/guard";
import { openaiAgentsContext } from "@arcjet/guard/openai-agents/v0";
import { arcjet } from "./arcjet.js";
const appContext = { sessionId: conversationId };
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...openaiAgentsContext({ context: appContext, conversationId }),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
await run(agent, userText, { context: appContext });
There is no guardInbound.
Step 4: Correlation
Put the id you already have on the app context you pass to run():
const appContext = { sessionId: conversationId };
await run(agent, userText, { context: appContext });
MemorySession({ sessionId }) and OpenAIConversationsSession({ conversationId }) already exist. Resolve the id yourself
(await session.getSessionId() only after you passed that id in) and
copy it onto context. openaiAgentsContext reads it; it never calls
createAgentContext and never calls getSessionId().
Preference order: context.correlationId, then context.sessionId,
then context.conversationId, then context.groupId, then the
envelope copies (conversationId, groupId, already-resolved
sessionId). If none is a valid 1–256 printable-ASCII string, the call
is uncorrelated rather than joined to a generated id nobody has.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI (before run), a tool deny, PII on args, a rate limit, and fail-closed (an unreachable guard).
- Confirm in the Arcjet dashboard that decisions share the session / conversation id as their correlation id.
- Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo will land in
arcjet/examples openai-agent
with arcjet/examples#193.
Do not add an example under examples/ in the JS SDK repo.
Note: capture events are fire-and-forget and batched, so events can lag the decisions they accompany by a few seconds. A dropped event is diagnosed, never thrown.