integrate-arcjet-guard-claude-agent-sdk-py
About integrate-arcjet-guard-claude-agent-sdk-py
This skill explains how to integrate Arcjet Guard with the official Python Claude Agent SDK to control prompts and tool calls. It distinguishes protections for authored tools, inbound prompts, and unwrapped tools, and covers session correlation and denial behavior.
Key features
- 01Protect authored tools with guard_tool
- 02Check prompts and tools with guard_hooks
- 03Separate inbound blocking from post-use capture
- 04Correlate calls with caller-owned session UUIDs
- 05Configure rate limits and fail-closed behavior
Use cases
- 01Block prompt injection before model input
- 02Limit custom tools with side effects
- 03Deny built-in or unwrapped MCP tools
- 04Test rate limits and Guard outage handling
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ arcjet/ skills --skill integrate-arcjet-guard-claude-agent-sdk-pyAfter installing, type/integrate-arcjet-guard-claude-agent-sdk-pyin Claude Code to confirm it loaded.
Details
From the project READMEIntegrate Arcjet Guard into the Python Claude Agent SDK
arcjet.guard.claude_agent_sdk 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 Agent SDK-specific.
Official claude-agent-sdk>=0.2.127,<1 only — not the JS
@anthropic-ai/claude-agent-sdk adapter
(@arcjet/guard/claude-agent-sdk/v0, docs
https://docs.arcjet.com/guards/claude-agent-sdk/), not Vercel AI, not
community forks, and not Claude Managed Agents hosted
client.beta.sessions (arcjet.guard.claude_managed_agents). Importing
arcjet.guard.claude_agent_sdk does not load LangChain, CrewAI, or the
JS adapter. The extra is safe (no chromadb).
Exports: guard_tool, guard_hooks, claude_agent_context. Authored
@tool + create_sdk_mcp_server for tools you own; guard_hooks for
inbound UserPromptSubmit and unwrapped built-ins / MCP PreToolUse.
There is no inbound helper and no guard_can_use_tool.
Three surfaces, one decision rule:
- An authored
@tool→guard_tool. Denial is JSON-in-content +is_error: True. Python does not forwardstructuredContent(that is the JS adapter). Do not raise. - Inbound text →
guard_hooksUserPromptSubmit(decision: "block"). That is the only place a turn can be declined before the model reads the prompt. - Built-ins / unwrapped MCP →
guard_hooksPreToolUse(permissionDecision: "deny").PostToolUseis capture only. - Correlation →
claude_agent_contextreads a caller-owned UUIDsession_id. It never mints.
Docs: https://docs.arcjet.com/guards/claude-agent-sdk/.
Unwrapped tools deny on PreToolUse via guard_hooks
Built-ins (Bash, Write, …) and MCP tools you did not pass through
guard_tool have no authored handler. PreToolUse is the only deny for
those. PostToolUse cannot undo a tool that already ran. List every
guard_tool wrapper in exclude or each authored tool is guarded twice
(two round trips, two quota units). Entries match the reported name:
pass {"server": "support", "name": "lookup_order"} for an authored MCP
tool (it resolves to mcp__support__lookup_order) and a bare string for
a built-in such as "Bash". A bare authored name deliberately does not
match every server's tool of that name.
Authored @tool denial is JSON-in-content + is_error: True
guard_tool wraps the @tool definition so the handler never runs on
DENY (or unevaluated Guard under the default on_guard_error="deny").
The model receives the ArcjetDenialResult as JSON text on content
with is_error: True. Do not set structuredContent (JS only). Do
not raise: a throw is a raw exception; omitting is_error looks
like success. Same fail-closed default as
#196: only "allow"
fails open; a DENY always blocks. Core guard() still fails open.
can_use_tool is not a policy gate
can_use_tool is human-in-the-loop. allowed_tools, allow rules, and
bypassPermissions / acceptEdits skip it. Same trap as CrewAI
human_input, JS canUseTool, LangGraph interrupt(), and OpenAI
Agents needs_approval. There is no inbound helper — screen prompt
injection on guard_hooks UserPromptSubmit.
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
guard_tool. Built-ins and unwrapped MCP getguard_hooksPreToolUse. - 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. Session id is the correlation id, not the user. The
Claude SDK requires
session_idto be a UUID and allows a given id to be created only once — later turns useresume. - Is an Arcjet outage unacceptable? Every helper defaults to
on_guard_error="deny". Ask explicitly about inboundUserPromptSubmit: failing closed there means the agent stops answering, so"allow"is a routine and legitimate choice at that one call site.
The things readers get wrong
- This is not the JS adapter and not Claude Managed Agents. Do not
import
@arcjet/guard/claude-agent-sdk/v0orarcjet.guard.claude_managed_agents. - There is no inbound helper. Screen on
UserPromptSubmitviaguard_hooks. can_use_toolis HITL, not policy.- Python does not forward
structuredContent. Authored deny is JSON-in-content +is_error: True. session_idis a UUID, and only once. Later turns:resume.claude_agent_contextnever mints.- List every
guard_toolwrapper inexclude. Missing exclude double-calls Guard. - Key rate limits on the authenticated caller, not a model-supplied
order id. Hand
query/create_sdk_mcp_serverthe copyguard_toolreturns. - Do not hand-wrap every tool with raw
guard(). - Isolation needs both
setting_sources=[]andstrict_mcp_config=True. One without the other still exposes an unguarded path. - Two distinct ids.
ClaudeAgentOptions.session_idis a unique UUID per run (resumelater). The Guardsession_idis a long-lived actor id. A missing decision is not a denial.
Step 1: Install and find the guard client
Requires PyPI arcjet 1.1.0 (the arcjet[claude-agent-sdk] extra is not in 1.0.0):
pip install "arcjet[claude-agent-sdk]"
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"])
Step 2: Gate authored tools — guard_tool
from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server, query, tool
from arcjet.guard import DetectPromptInjection, TokenBucket, launch_arcjet
from arcjet.guard.claude_agent_sdk import guard_hooks, guard_tool
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,
)
mcp_limit = TokenBucket(
label="mcp.invoked",
bucket="mcp-access",
refill_rate=20,
interval_seconds=60,
max_tokens=20,
)
inbound = DetectPromptInjection()
user_id = authenticated_user_id
# Caller-owned UUID for this conversation. Later turns: resume=session_id.
session_id = conversation_id
@tool("lookup_order", "Look up an order by number", {"order_id": str})
async def lookup_order(args: dict) -> dict:
return {
"content": [{"type": "text", "text": f"{args['order_id']}: 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",
)
# can_use_tool=... is HITL — not this policy gate
Step 3: Screen inbound and gate unwrapped tools — guard_hooks
async for message in query(
prompt=user_text,
options=ClaudeAgentOptions(
session_id=session_id, # later turns: resume=session_id instead
mcp_servers={
"support": create_sdk_mcp_server(name="support", tools=[lookup_order]),
},
hooks=guard_hooks(
guard=aj,
session_id=session_id,
exclude=[{"server": "support", "name": "lookup_order"}],
inbound={
"action": "message.received",
"rules": lambda ctx: [inbound(ctx["prompt"])],
},
action="mcp.invoked",
rules=lambda ctx: [mcp_limit(key=user_id, requested=1)],
on_guard_error="deny",
),
),
):
pass
Step 4: Correlation
claude_agent_context(session_id=session_id) reads that same
caller-owned UUID (hook session_id first, then options.session_id).
It never mints. Pass the id you already have into guard_hooks and
ClaudeAgentOptions — do not derive a new one per turn. The Claude SDK
also requires session_id to be a UUID and allows a given id to be
created only once — later turns use resume.
Verify the integration
python -m py_compile(or the project's type-check) passes.- Exercise inbound PI (
UserPromptSubmitblock), an authored-tool deny (JSON-in-content +is_error: True, nostructuredContent), a built-in / unwrapped MCP deny (PreToolUse), a rate limit, and fail-closed (an unreachable guard). Confirm a wrapped tool produces one guard decision per invocation — a second decision under thePreToolUseaction means a missingexcludeentry. - Confirm in the Arcjet Console / CLI that decisions share the
caller-owned UUID
session_id. - Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
The example fastapi-claude-agent-sdk-guard stays with Runtime — do not
invent a new example name. Do not add an example in this skills repo.