Integrate Arcjet Guard into Python Claude Managed Agents
arcjet.guard.claude_managed_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.
Load that reference for anything that is not Claude Managed
Agents-specific.
Official anthropic>=0.92.0,<2 only — not claude-agent-sdk, not
arcjet.guard.claude_agent_sdk, not the JS
@anthropic-ai/claude-agent-sdk adapter, and not JS Claude Managed
Agents (@arcjet/guard/claude-managed-agents/v0, docs
https://docs.arcjet.com/guards/claude-managed-agents/). Importing
arcjet.guard.claude_managed_agents does not load Claude Agent SDK,
LangChain, or CrewAI. The extra is safe (no chromadb).
This is the hosted Claude Managed Agents harness
(client.beta.sessions). Anthropic runs the agent loop and the built-in
toolset (bash, files, web_*). The agent toolset defaults to
always_allow, so there is no customer pre-exec for bash/files —
agent.tool_use / agent.tool_result fire after the built-in already
ran. There is no PreToolUse. Do not paper over that gap with
always_ask. It is not Claude Agent SDK local query() / guard_hooks.
Exports: guard_custom_tool, guard_events,
claude_managed_agents_context. There is no guard_inbound, no
guard_tool, and no guard_tool_confirmation.
Three surfaces, one decision rule:
- Inbound text (
user.message/initial_events) →guard_eventswrapssessions.events.send. DENY raisesArcjetDeniedError/ArcjetUnavailableErrorand does not call send. - A custom tool you execute (
agent.custom_tool_use) →guard_custom_tool(run=…). DENY does not run the originalrun; the helper sendsuser.custom_tool_resultwith schema fieldis_error. - Correlation →
claude_managed_agents_contextreads a caller-ownedcorrelation_id/session_id. It never mints. It never reads Anthropicsession.id/sevt_….
Docs: https://docs.arcjet.com/guards/claude-managed-agents/ (shared
JS+Python page). Do not use /guards/claude-agent-sdk/. There is no separate
/guards/claude-managed-agents-py/ page. JS adapter is not in npm
1.11.0 — pin @arcjet/guard to cb35c8f92c3a2fb63fbeb9b386d79b1878c19d92.
The real gates are inbound user.message and custom tools
guard_events(send=client.beta.sessions.events.send, …) wraps the send
callable so user.message / initial_events are evaluated before
the original send runs — the only place a turn can be declined before
the hosted harness reads the prompt. Inbound rules receive
{"prompt", "content", "type"} from message_arguments() — not the JS
{ text, events }. guard_custom_tool(run=…) returns
await handler(event, send=…, session_id=…). Built-ins never enter that
handler. Optional tool= wraps a self-hosted @beta_tool run the
same way; the CLI worker cannot register custom tools.
Custom-tool denial is user.custom_tool_result with is_error
On DENY (or unevaluated Guard under the default
on_guard_error="deny") the original run is not called. The helper
sends a real user.custom_tool_result (custom_tool_use_id, JSON of
ArcjetDenialResult on content, is_error — that field is on the
events schema; do not invent a second one). Do not raise from the
hosted handler: a throw leaves the session idle. Omitting is_error
looks like success. This is not Claude Agent SDK structuredContent.
Same fail-closed default as
#196. Core guard()
still fails open.
always_ask + user.tool_confirmation is not HITL-as-policy
Permission policies apply to the agent toolset and MCP, not custom
tools. Same trap as CrewAI human_input, JS canUseTool, and LangGraph
interrupt(). MCP Guard only on servers you host — Anthropic is the
MCP client. web_search / web_fetch always run on Anthropic.
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which custom tools does the app execute on
agent.custom_tool_use? Those getguard_custom_tool. Built-ins underalways_allowcannot. - What limits? (e.g. "10 lookups/min per user" →
TokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: none. Anthropic
session.id/sevt_…are not the correlation id. - Is an Arcjet outage unacceptable? Every helper defaults to
on_guard_error="deny". Ask explicitly about inboundguard_events: failing closed there means the turn is not sent, so"allow"is a routine and legitimate choice at that one call site.
The things readers get wrong
- This is not the Claude Agent SDK. No
guard_tool, noguard_hooks, noPreToolUse, noUserPromptSubmit. Do not also wrap witharcjet.guard.claude_agent_sdkor@arcjet/guard/claude-agent-sdk/v0. - There is no
guard_inbound. The helper isguard_events. - Inbound ctx is
{"prompt", "content", "type"}, not JS{ text, events }. - Correlation is caller-owned, never minted. Do not pass Anthropic
session.id/sevt_…/trace_idas correlation. - Default
always_allowcannot be gated. Do not claim we can block Anthropic-cloud bash/files. - On custom-tool DENY, send
user.custom_tool_resultwithis_error. Do not raise. On ALLOW the caller still sends the success result or the session idles. always_ask+user.tool_confirmationis opt-in confirmation, not policy.- Do not hand-wrap every session event with raw
guard(). guard_eventshas noinbound=.actionandrulessit at the top level; it takessend=; the returned callable replacessendand raises on DENY.guard_custom_tooltakesrun=, nottool=<function>. A missing decision is not a denial.
Step 1: Install and find the guard client
Until-published: PyPI arcjet 1.0.0 does not include this module. Pin
arcjet to git SHA 582372916d70311873ef24b7a72443c098b3aec9 (current
main, includes the extra):
pip install "arcjet[claude-managed-agents] @ git+https://github.com/arcjet/arcjet-py.git@582372916d70311873ef24b7a72443c098b3aec9"
If the agent has no guard client yet, launch one once at module scope:
import os
from arcjet.guard import launch_arcjet
aj = launch_arcjet(key=os.environ["ARCJET_KEY"])
Worked example:
examples/fastapi-claude-managed-agents-guard
(pins 58237291).
Step 2: Screen inbound before sessions.events.send
from anthropic import Anthropic
from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet
from arcjet.guard.claude_managed_agents import (
claude_managed_agents_context,
guard_custom_tool,
guard_events,
)
client = Anthropic()
aj = launch_arcjet(key=os.environ["ARCJET_KEY"])
inbound = DetectPromptInjection()
user_id = authenticated_user_id
conversation_id = authenticated_conversation_id
derived = claude_managed_agents_context(session_id=conversation_id)
send = guard_events(
guard=aj,
send=client.beta.sessions.events.send,
action="message.received",
# Python inbound ctx is {"prompt", "content", "type"} — not JS { text, events }.
rules=lambda ctx: [inbound(ctx["prompt"])],
session_id=derived.correlation_id or conversation_id,
)
session = client.beta.sessions.create(agent=agent_id, environment_id=environment_id)
# Anthropic minted session.id — pass it to the sessions API, not as correlation.
# Screen user.message before the hosted harness reads it. DENY raises
# ArcjetDeniedError / ArcjetUnavailableError and does not send.
await send(
session.id,
events=[{"type": "user.message", "content": [{"type": "text", "text": user_text}]}],
)
Step 3: Gate custom tools on agent.custom_tool_use
lookup_limit = TokenBucket(
label="order.looked-up",
bucket="lookups",
refill_rate=10,
interval_seconds=60,
max_tokens=10,
)
async def lookup_order(event) -> dict:
order_id = event.input["order_id"]
return {"content": [{"type": "text", "text": f"{order_id}: shipped"}]}
handle_lookup = guard_custom_tool(
guard=aj,
action="order.looked-up",
run=lookup_order,
rules=[lookup_limit(key=user_id, requested=1)],
session_id=derived.correlation_id or conversation_id,
)
with client.beta.sessions.events.stream(session_id=session.id) as stream:
for event in stream:
if event.type == "agent.custom_tool_use" and event.name == "lookup_order":
# DENY posts user.custom_tool_result with is_error. Do not raise.
await handle_lookup(
event,
send=client.beta.sessions.events.send,
session_id=session.id,
)
# agent.tool_use / always_ask + user.tool_confirmation are not this policy gate
Key rate limits on the authenticated caller, not a model-supplied order
id. On ALLOW the caller still sends the success user.custom_tool_result.
Step 4: Correlation
claude_managed_agents_context is a reader that returns
ClaudeManagedAgentsContext (a dataclass) — not a context manager and
not a contextvar setter. It reads a caller-owned correlation_id /
session_id. It never mints. It never reads Anthropic id / event_id
/ session.id (ses_…) / sevt_… / trace_id. Passing an Anthropic
Session object is safe — those minted ids are ignored. An invalid
candidate is skipped; if nothing valid remains the call is uncorrelated
rather than joined to a generated id. Helpers also re-read
session_id= / correlation_id= internally.
Verify the integration
python -m py_compile(or the project's type-check) passes.- Exercise inbound PI (send is not called), a custom-tool deny
(
user.custom_tool_resultwithis_error;runis not called), a rate limit, and fail-closed (an unreachable guard). Confirmalways_ask/user.tool_confirmationis never treated as the gate and built-inagent.tool_useis observe-only. - Confirm in the Arcjet Console / CLI that decisions share the
caller-owned conversation id — not an Anthropic
session.id/sevt_…. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
Worked example:
examples/fastapi-claude-managed-agents-guard.
Do not invent a second example name. Do not add an example in this
skills repo.