integrate-arcjet-guard-openai-agents-py · git:20260916.f8959e1 · 2026-09-16 · sha256 49dfff17b0f98f12
integrate-arcjet-guard-openai-agents-py git:20260916.f8959e1A
Immutable. This exact content is served forever at /api/v1/blob/49dfff17b0f98f12.
---
name: integrate-arcjet-guard-openai-agents-py
description: Integrate Arcjet Guard into Python OpenAI Agents — wrap FunctionTool / function_tool with guard_tool and read a caller-owned session or conversation id via openai_agents_context. Use when asked to add Arcjet to openai-agents, Runner.run, rate limit those tools, screen inbound messages, or block prompt injection / PII. This is Python openai-agents, not the JS @openai/agents adapter.
license: Apache-2.0
compatibility: Requires Python >= 3.10 and official openai-agents>=0.19.0,<1 via arcjet[openai-agents] on PyPI arcjet 1.0.0+. This is text Agent + Runner.run + authored FunctionTool — not Realtime, Sandbox, hosted, MCP, Computer / Shell / ApplyPatch, handoffs, or Agent.as_tool().
metadata:
author: arcjet
type: core
library: arcjet
---
# Integrate Arcjet Guard into Python OpenAI Agents
`arcjet.guard.openai_agents` wraps the agent's existing Arcjet client. It
never talks to the Arcjet API itself. Shared Guard fundamentals (client,
rules, labels, decisions, capture, registration) live in
[../arcjet/references/guards_python.md](../arcjet/references/guards_python.md).
Load that reference for anything that is not OpenAI Agents-specific.
Official `openai-agents>=0.19.0,<1` only — not the JS `@openai/agents`
adapter (`@arcjet/guard/openai-agents/v0`, docs
https://docs.arcjet.com/guards/openai-agents/), not community forks.
Importing `arcjet.guard.openai_agents` does not load LangChain.
Exports: `guard_tool`, `openai_agents_context`. Authored `FunctionTool` /
`@function_tool` only. Not hosted tools, MCP, Computer / Shell /
ApplyPatch, handoffs, or `Agent.as_tool()`.
Two surfaces, one decision rule:
- **An authored `FunctionTool`** → `guard_tool`. Gate is
`FunctionTool.tool_input_guardrails` + `reject_content` (JSON of
`ArcjetDenialResult`). Do **not** raise.
- **Correlation** → `openai_agents_context` reads a caller-owned session
/ conversation id. It never mints. It never reads `trace_id`.
Docs: https://docs.arcjet.com/guards/openai-agents/.
## The gate is `tool_input_guardrails` + `reject_content`
`guard_tool` returns a copy whose input guardrails start with Arcjet, so
`on_invoke_tool` never runs on `DENY` (or unevaluated Guard under the
default `on_guard_error="deny"`). Denial is
`ToolGuardrailFunctionOutput.reject_content` with JSON of
`ArcjetDenialResult` (`{ arcjetDenied: true, … }`). Do **not** raise —
`raise_exception()` is a tripwire halt, and a raise from
`on_invoke_tool` is swallowed by `default_tool_error_function`. Same
fail-closed default as [#196](https://github.com/arcjet/arcjet-py/pull/196):
only `"allow"` fails open; a `DENY` always blocks. Core `guard()` still
fails open (`has_failed_open()`).
## `needs_approval` is not a policy gate
`needs_approval` is human-in-the-loop (`state.approve` / `state.reject`).
Same trap as JS OpenAI Agents `needsApproval`, LangGraph `interrupt()`,
and Genkit `interrupt()`. There is no inbound helper and no approval
helper. `RunConfig.tool_execution.pre_approval_tool_input_guardrails=True`
is an application opt-in only — this helper does not set it.
## Screen inbound before `Runner.run`
There is no inbound helper. SDK `input_guardrails` / `output_guardrails`
/ tool output guardrails are the SDK's own tripwires, not Arcjet. Call
`aj.guard(...)` in the application and **act on the decision**. Core
`guard()` fails open: `ALLOW` is not proof the rules ran. Gate on
`decision.has_failed_open()` if this call site must fail closed;
`guard_tool` already defaults to that.
## 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 `guard_tool`. Hosted / MCP /
handoffs / `as_tool` are out of scope.
2. What **limits**? (e.g. "10 lookups/min per user" → `TokenBucket`.)
3. Who is the **user** for metadata — an opaque user/tenant ID (never PII)?
Default: none. Put the conversation / session id you already have on
the app context you pass to `Runner.run`. That id is the correlation
id, not the user.
4. Is an Arcjet outage unacceptable? Every helper defaults to
`on_guard_error="deny"`. Ask explicitly about inbound screening before
`Runner.run`: failing closed there means the agent does not run, so
`"allow"` is a routine and legitimate choice at that one call site.
## The things readers get wrong
1. **This is not the JS adapter.** Do not import
`@arcjet/guard/openai-agents/v0`.
2. **There is no inbound helper.** SDK guardrails are not Arcjet. Screen
with core `guard()` before `Runner.run`.
3. **`needs_approval` is HITL, not policy.**
4. **Denial is `reject_content` only.** A raise is a tripwire halt or is
swallowed by `default_tool_error_function`.
5. **Correlation is read, never minted.** Never `trace_id`. Never
construct `OpenAIConversationsSession()`.
6. **Key rate limits on the authenticated caller**, not a model-supplied
order id. Hand the agent the copy `guard_tool` returns — the original
stays unguarded.
7. **Do not hand-wrap every tool with raw `guard()`.**
8. **`inputs=` is accepted.** A missing decision is not a denial —
verify in Console/CLI.
## Step 1: Install and find the guard client
This module ships in PyPI `arcjet` **1.0.0**:
```bash
pip install "arcjet[openai-agents]"
```
If the agent has no guard client yet, launch one **once at module scope**:
```python
import os
from arcjet.guard import launch_arcjet
aj = launch_arcjet(key=os.environ["ARCJET_KEY"])
```
## Step 2: Gate authored tools — `guard_tool`
```python
from agents import Agent, Runner, function_tool
from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet
from arcjet.guard.openai_agents import guard_tool, openai_agents_context
aj = launch_arcjet(key=os.environ["ARCJET_KEY"])
lookup_limit = TokenBucket(
label="order.looked-up",
bucket="lookups",
refill_rate=10,
interval_seconds=60,
max_tokens=10,
)
user_id = authenticated_user_id
@function_tool # needs_approval=... is HITL — not this policy gate
def lookup_order(order_number: str) -> dict:
"""Look up an order by number."""
return {"order_number": order_number, "status": "shipped"}
lookup_order = guard_tool(
guard=aj,
tool=lookup_order,
action="order.looked-up",
rules=[lookup_limit(key=user_id, requested=1)],
on_guard_error="deny",
)
agent = Agent(
name="support-agent",
instructions="Help the user.",
tools=[lookup_order],
)
```
Use `action` + `rules` on `guard_tool`.
## Step 3: Screen inbound before `Runner.run`
```python
inbound = DetectPromptInjection()
app_context = {"session_id": conversation_id}
derived = openai_agents_context(app_context)
decision = await aj.guard(
label="message.received",
rules=[inbound(user_text)],
correlation_id=derived.correlation_id,
)
if decision.conclusion == "DENY":
raise Exception("message blocked")
if decision.has_failed_open():
raise Exception("inbound guard unavailable")
await Runner.run(agent, user_text, context=app_context)
```
There is no inbound helper.
## Step 4: Correlation
`openai_agents_context` reads a caller-owned id. Preference: fields on
`runContext.context` / a bare app object (`correlation_id`, then
`session_id`, then `conversation_id`, then `group_id`, snake or
camelCase), then the same names on the envelope, then
`correlation_id=` / `session_id=` kwargs, then an enclosing
`arcjet_sequence`. It returns `OpenAIAgentsContext` — pass
`.correlation_id` to `guard()`. It never mints an id. It never reads
`trace_id`. It never constructs `OpenAIConversationsSession()`. Do not
invent a correlation id per turn.
## Verify the integration
1. `python -m py_compile` (or the project's type-check) passes.
2. Exercise inbound PI (before `Runner.run`), a tool deny
(`reject_content`, not a raise), a rate limit, and fail-closed (an
unreachable guard). Confirm `needs_approval` is never treated as the
gate.
3. Confirm in the Arcjet Console / CLI that decisions share the
caller-owned session / conversation id — not a `trace_id`.
4. Manual E2E with a real `ARCJET_KEY` is still-to-verify until you run it.
The example `fastapi-openai-agents-guard` stays with Runtime — do not
invent a new example name. Do not add an example in this skills repo.