# Integrate Arcjet Guard Openai Agents

> Integrate Arcjet security into an OpenAI Agents text Agent using @arcjet/guard — wrap tool({ execute }), screen inbound before run(), and read a caller-owned id from runContext.context. Use when asked to add Arcjet to @openai/agents, rate limit its tools, screen inbound messages, or block prompt injection / PII.

- Skill: `arcjet/integrate-arcjet-guard-openai-agents` (Agent Skill)
- Install (CLI): `npx skillmds@latest add arcjet/integrate-arcjet-guard-openai-agents`
- Raw SKILL.md: https://api.skillmd.com/api/skills/arcjet/integrate-arcjet-guard-openai-agents/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: Apache-2.0
- Author: arcjet (https://skillmd.com/u/arcjet)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/arcjet/integrate-arcjet-guard-openai-agents

---


# 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()`. After
  `tool()` the object is a `FunctionTool`; the runner calls `invoke`.
  DENY returns a structured `ArcjetDenialResult`. Do not throw.
- **Correlation** → `openaiAgentsContext()` reads a field the integrator
  put on `runContext.context` (then documented copies: `conversationId`,
  `groupId`, already-resolved `sessionId`). It never mints a new id.
  It never calls `session.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.

1. Which tools are **risky** (external side effects, irreversible, spends
   money, sends messages)? Those get `guardTool`. Hosted / MCP / handoffs
   are out of v0 scope.
2. What **limits**? (e.g. "10 lookups/min per order" → `tokenBucket`.)
3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)?
   Default: none. Pass it via `metadata` on the policy. Put the
   conversation / session id you already have on
   `run(..., { context: { sessionId } })`. That id is the correlation id,
   not the user.
4. Is an Arcjet outage unacceptable? Every helper defaults to
   `onGuardError: "deny"`. Ask explicitly about inbound screening before
   `run()`: 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

1. **There is no `guardInbound`.** Screen prompt injection before
   `run()`. SDK input/output guardrails are not Arcjet.
2. **`needsApproval` is not a policy gate.** It is HITL. Use `guardTool`.
3. **The import path is versioned and there is no alias.**
   `@arcjet/guard/openai-agents/v0`. `@arcjet/guard/openai-agents` does
   not resolve.
4. **Correlation is read, never minted.** Do not call `createAgentContext`
   inside a run callback — that generates a second id and splits the
   Sequence. `RunContext` has no session / conversation id of its own.
   Put the id you already chose on `run(..., { context })`. Do not call
   `session.getSessionId()` from the helper: `MemorySession` mints a UUID
   when constructed without `sessionId`. Do not use `traceId` (the SDK
   mints one when omitted).
5. **Do not double-wrap with `@arcjet/guard/vercel-ai/v7` or
   `@arcjet/guard/claude-managed-agents/v0`.** `guardTool` throws if the
   tool already carries the Arcjet protection brand.
6. **A denial from `guardTool` is a structured object, not a throw.**
   Throwing would hit the SDK `errorFunction` (a generic string, or
   `ToolCallError` when `outputSchema` / `errorFunction: null`). The
   runner stringifies the object onto a `function_call_result` with
   `status: "completed"` — the denial is in the payload
   (`arcjetDenied: true`). If `onDeny` throws, 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 })`.

```sh
npm install @arcjet/guard @openai/agents
```

If the agent has no guard client yet, launch one **once at module scope**:

```ts
import { launchArcjet } from "@arcjet/guard";

export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
```

## Step 2: Gate authored tools

```ts
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 `rules` to submit none. The guard call still happens.
- Optional `actor` and `inputs` (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 with `policyInput`.
- On DENY the closed-over `execute` never 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 sets `outputGuardrails` or `customDataExtractor`,
  those receive the denial object and must not assume the tool's own shape.

## Step 3: Screen inbound before run

```ts
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()`:

```ts
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

1. `npm run typecheck` passes.
2. Exercise inbound PI (before run), a tool deny, PII on args, a rate
   limit, and fail-closed (an unreachable guard).
3. Confirm in the Arcjet dashboard that decisions share the session /
   conversation id as their correlation id.
4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it.

A full working demo will land in
[`arcjet/examples` `openai-agent`](https://github.com/arcjet/examples/tree/main/examples/openai-agent)
with [arcjet/examples#193](https://github.com/arcjet/examples/pull/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.

