integrate-arcjet-guard-claude-agent-sdk-py

v2026.09.25

Integrate Arcjet Guard into the Python Claude Agent SDK — wrap authored @tool with guard_tool, and use guard_hooks for UserPromptSubmit inbound plus PreToolUse on unwrapped built-ins / MCP. Use when asked to add Arcjet to claude-agent-sdk, rate limit those tools, screen inbound prompts, or block prompt injection / PII. This is Python claude-agent-sdk, not the JS adapter and not Claude Managed Agents hosted sessions.

GitHub
Install command
npx skhub add arcjet/integrate-arcjet-guard-claude-agent-sdk-py
Markdown
SKILL.md

Integrate 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 forward structuredContent (that is the JS adapter). Do not raise.
  • Inbound text → guard_hooks UserPromptSubmit (decision: "block"). That is the only place a turn can be declined before the model reads the prompt.
  • Built-ins / unwrapped MCP → guard_hooks PreToolUse (permissionDecision: "deny"). PostToolUse is capture only.
  • Correlation → claude_agent_context reads a caller-owned UUID session_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.

  1. Which tools are risky (external side effects, irreversible, spends money, sends messages)? Those get guard_tool. Built-ins and unwrapped MCP get guard_hooks PreToolUse.
  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. Session id is the correlation id, not the user. The Claude SDK requires session_id to be a UUID and allows a given id to be created only once — later turns use resume.
  4. Is an Arcjet outage unacceptable? Every helper defaults to on_guard_error="deny". Ask explicitly about inbound UserPromptSubmit: 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

  1. This is not the JS adapter and not Claude Managed Agents. Do not import @arcjet/guard/claude-agent-sdk/v0 or arcjet.guard.claude_managed_agents.
  2. There is no inbound helper. Screen on UserPromptSubmit via guard_hooks.
  3. can_use_tool is HITL, not policy.
  4. Python does not forward structuredContent. Authored deny is JSON-in-content + is_error: True.
  5. session_id is a UUID, and only once. Later turns: resume. claude_agent_context never mints.
  6. List every guard_tool wrapper in exclude. Missing exclude double-calls Guard.
  7. Key rate limits on the authenticated caller, not a model-supplied order id. Hand query / create_sdk_mcp_server the copy guard_tool returns.
  8. Do not hand-wrap every tool with raw guard().
  9. Isolation needs both setting_sources=[] and strict_mcp_config=True. One without the other still exposes an unguarded path.
  10. Two distinct ids. ClaudeAgentOptions.session_id is a unique UUID per run (resume later). The Guard session_id is 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

  1. python -m py_compile (or the project's type-check) passes.
  2. Exercise inbound PI (UserPromptSubmit block), an authored-tool deny (JSON-in-content + is_error: True, no structuredContent), 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 the PreToolUse action means a missing exclude entry.
  3. Confirm in the Arcjet Console / CLI that decisions share the caller-owned UUID session_id.
  4. Manual E2E with a real ARCJET_KEY is 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.

Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

Apache-2.0

Source path

integrate-arcjet-guard-claude-agent-sdk-py

Default branch

main

Latest commit

4ae5d84

Tree SHA

ba84ae4