Claude Agent SDK Python agent guard
Claude Agent SDK
Python agents call authored @tool handlers, built-in tools, and MCP
tools. Arcjet Guard sits at those boundaries so a policy can allow or
deny the action before a side effect runs.
What is Arcjet?
Arcjet is the runtime security platform that ships with your code. Enforce budgets, stop prompt injection, detect bots, and protect personal information with Arcjet's AI security building blocks.Use protect() on HTTP routes. Use
the helpers on this page for agent tools and other actions that have no HTTP
request.
You need an Arcjet account and an ARCJET_KEY. Launch
one client at module scope and reuse it.
Framework wrappers take an action string such as email.sent. That slug
selects the matching remote policy
and names the event in the Arcjet Console. Direct guard() calls use the field
name label for the same slug. Don’t pass label to a wrapper such as
guardTool().
You can submit SDK rules in code, rely on a published remote policy, or combine both. For more information about the decision model, see Agent guards.
This adapter is the Python claude-agent-sdk package plus
guard_tool, guard_hooks, and claude_agent_context. It is not
the JavaScript @arcjet/guard/claude-agent-sdk/v0 adapter. Don’t
wrap these tools with @arcjet/guard/claude-agent-sdk/v0.
Install
Section titled “Install”Until arcjet.guard.claude_agent_sdk is published, pin arcjet to
9ea0b06a.
Published arcjet 1.0.0 on PyPI does not include this extra.
pip install "arcjet[claude-agent-sdk] @ git+https://github.com/arcjet/arcjet-py@9ea0b06a87bcee77b8df0664338c712c4668b87b"arcjet[claude-agent-sdk] depends on claude-agent-sdk>=0.2.127,<1.
CPython 3.10 or later.
Import helpers from arcjet.guard.claude_agent_sdk:
from arcjet.guard.claude_agent_sdk import ( claude_agent_context, guard_hooks, guard_tool,)Launch one client at module scope:
from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)Use launch_arcjet_sync with Flask, Django, or other sync code.
Helpers
Section titled “Helpers”Pick the surface that matches what you hold when the effect runs:
| You have | Use | Needs | Blocks a call? |
|---|---|---|---|
| Any Python callable | guard_action / guard_action_sync | arcjet | Yes |
An authored @tool | guard_tool | arcjet[claude-agent-sdk] | Yes |
| An unwrapped built-in or MCP tool | guard_hooks (PreToolUse) | arcjet[claude-agent-sdk] | Yes |
| Inbound user text | guard_hooks (UserPromptSubmit) | arcjet[claude-agent-sdk] | Yes |
A caller-owned UUID session_id | claude_agent_context | arcjet[claude-agent-sdk] | No |
guard_tool wraps an authored @tool. On DENY, or when Guard
cannot be evaluated and on_guard_error is "deny", the helper
returns JSON of ArcjetDenialResult in content with
is_error: True. The tool handler does not run. Python does not
forward structuredContent. The model reads JSON from content.
Don’t raise. A raise is a raw exception. Omitting is_error looks
like success.
guard_hooks screens inbound text on UserPromptSubmit and denies
unwrapped built-in and MCP tools on PreToolUse. There is no
inbound helper. Screen prompt injection on the hooks inbound path.
claude_agent_context reads a caller-owned UUID session_id from
hook input, then the session_id= fallback. It never mints an ID.
It never reads trace_id. If the caller did not pass a UUID
session_id, the call is uncorrelated.
There is no guard_inbound. There is no guard_can_use_tool and no
guard_approval. can_use_tool is human-in-the-loop (HITL)
confirmation, not policy.
Don’t also wrap these tools with @arcjet/guard/claude-agent-sdk/v0.
Helper options
Section titled “Helper options”guard_tool accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
rules | No | Bound SDK rule inputs, or a function of the parsed tool arguments. Omit to submit none. |
session_id | No | Caller-owned UUID. An authored handler has no extra.session_id, so pass the same id you give ClaudeAgentOptions. Never minted. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
guard_hooks accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
action | No | Guard label and capture name. A string, or a function of the hook input. Defaults to {tool_name}.invoked when a tool hook is registered. |
rules | No | Bound SDK rule inputs, or a function of the tool arguments. Omit to submit none. |
inbound | No | Policy for UserPromptSubmit. Requires action. rules receives {"prompt": ...}. |
exclude | No | Tools already wrapped with guard_tool. Pass {"server": ..., "name": ...} to match mcp__{server}__{name}, or a bare string for a built-in such as "Bash". |
session_id | No | Caller-owned UUID fallback. Hook session_id is preferred. Never minted. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
guard_hooks needs a tool policy (action or rules), an
inbound policy, or both. There is no guard_inbound.
claude_agent_context reads a caller-owned UUID session_id from
hook input, then the session_id= fallback. It never mints an ID.
It never reads trace_id.
Screen inbound with UserPromptSubmit
Section titled “Screen inbound with UserPromptSubmit”There is no inbound helper. Screen prompt injection on
guard_hooks inbound in UserPromptSubmit. This is the only place
a turn can be declined before the model sees the prompt.
On DENY, UserPromptSubmit returns {"decision": "block"}. The
model never sees the prompt.
Helpers default to on_guard_error="deny". "allow" is a
legitimate choice on inbound, because failing closed there stops the
agent answering during an outage.
Pass the UUID session_id that you already have. Don’t mint one.
If you omit a UUID session_id, the call is uncorrelated.
from claude_agent_sdk import ClaudeAgentOptions, queryfrom arcjet.guard import DetectPromptInjection, launch_arcjetfrom arcjet.guard.claude_agent_sdk import guard_hooks
arcjet = launch_arcjet(key=ARCJET_KEY)inbound = DetectPromptInjection()
options = ClaudeAgentOptions( session_id=conversation_id, hooks=guard_hooks( guard=arcjet, session_id=conversation_id, inbound={ "action": "message.received", "rules": lambda arguments: [inbound(arguments["prompt"])], }, ),)
async for message in query(prompt=user_text, options=options): passGate authored @tool handlers
Section titled “Gate authored @tool handlers”guard_tool wraps an authored @tool. That authored handler is the
local side effect this helper can stop.
On DENY the handler does not run. The helper returns JSON of
ArcjetDenialResult in content with is_error: True:
{ "arcjetDenied": true, "reason": "RATE_LIMIT", "message": "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.", "retryable": true, "retryAfterSeconds": 30}Python does not forward structuredContent. The model reads JSON
from content. Don’t raise. A raise is a raw exception. Omitting
is_error looks like success.
Scan free-text args (a note, reason, or body). An opaque order_id
does not trip email, phone, card, or IP, so don’t pass it to
LocalDetectSensitiveInfo. That helper runs on a local ML model
backend.
from claude_agent_sdk import toolfrom arcjet.guard import ( LocalDetectSensitiveInfo, TokenBucket, launch_arcjet,)from arcjet.guard.claude_agent_sdk import guard_tool
arcjet = launch_arcjet(key=ARCJET_KEY)lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)detect_pii = LocalDetectSensitiveInfo()
@tool("lookup_order", "Look up an order by ID", {"order_id": str, "note": str})async def lookup_order(args: dict) -> dict: return { "content": [ { "type": "text", "text": f"{args['order_id']}: shipped ({args['note']})", } ] }
guarded_lookup = guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", session_id=conversation_id, rules=lambda arguments: [ lookup_limit(key="orders", requested=1), detect_pii(arguments["note"]), ],)Deny unwrapped tools with PreToolUse
Section titled “Deny unwrapped tools with PreToolUse”Built-ins (Bash, Write, and similar) and MCP tools not passed
through guard_tool are gated here. PreToolUse returns
permissionDecision: "deny", so the tool does not run.
permissionDecision: "ask" is HITL, not deny.
Don’t apply guard_tool and PreToolUse to the same authored
tool. That double-calls the guard.
from arcjet.guard import DetectPromptInjection, TokenBucketfrom arcjet.guard.claude_agent_sdk import guard_hooks
mcp_limit = TokenBucket( refill_rate=20, interval_seconds=60, max_tokens=20, bucket="mcp-access",)
hooks = guard_hooks( guard=arcjet, session_id=conversation_id, action=lambda hook: f"{hook['tool_name']}.invoked", rules=[mcp_limit(key="mcp", requested=1)], exclude=[{"server": "support", "name": "lookup_order"}], inbound={ "action": "message.received", "rules": lambda arguments: [ DetectPromptInjection()(arguments["prompt"]) ], },)Pass hooks to ClaudeAgentOptions. Use this for tools you did
not pass through guard_tool.
PreToolUse fires for every tool. List your wrapped tools in
exclude or each one is guarded twice per invocation.
Entries match the reported name exactly. An authored tool arrives as
mcp__{server}__{name}, so pass {"server": ..., "name": ...} and
the qualified name is built for you. A bare string matches as-is,
which is what you want for a built-in such as "Bash". A bare
authored name does not match every server’s tool of that name.
Human approval is not a policy gate
Section titled “Human approval is not a policy gate”can_use_tool pauses the run for HITL confirmation.
allowed_tools, allow rules, and bypassPermissions /
acceptEdits can skip that callback.
There is no approval helper. Don’t wrap can_use_tool as Guard.
permissionDecision: "ask" is HITL, not deny. Use guard_tool on
authored @tool handlers you own, or PreToolUse through
guard_hooks for unwrapped tools.
Fail-closed default
Section titled “Fail-closed default”guard_tool and guard_hooks default to
on_guard_error="deny". If Guard cannot be evaluated, guard_tool
returns JSON of ArcjetDenialResult in content with
is_error: True and the tool handler does not run. Inbound
UserPromptSubmit blocks the prompt.
Set on_guard_error="allow" only when you can accept running the
action without a complete security decision. "allow" is a
legitimate choice on inbound UserPromptSubmit because failing
closed there stops the agent answering during an outage. The
helpers reject any other value.
A DENY conclusion always blocks, regardless of on_guard_error.
The core guard() call still fails open. It returns ALLOW, and
has_failed_open() returns True. The wrappers that sit around an
effect fail closed.
For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.
Correlation
Section titled “Correlation”query() has no conversation or session ID unless you pass one.
Pass the UUID session_id that you already have. Don’t mint an
ID. claude_agent_context reads hook session_id, then the
session_id= fallback. An authored handler has no
extra.session_id, so pass session_id= on guard_tool. If you
omit a UUID session_id, the call is uncorrelated rather than
joined to a generated ID. trace_id is never read.
The Claude Agent SDK requires session_id to be a UUID when you
pass one. Anything else exits with an invalid session ID error.
Reusing the same id on a second query() exits as already in use.
options = ClaudeAgentOptions( session_id=conversation_id, hooks=guard_hooks( guard=arcjet, session_id=conversation_id, inbound={ "action": "message.received", "rules": lambda arguments: [inbound(arguments["prompt"])], }, ),)Derive the ID from a session that the caller already has. A generated ID still joins this run’s events. It does not match an ID that you already search for.
What not to use
Section titled “What not to use”- There is no inbound helper. Screen prompt injection on
guard_hooksinbound inUserPromptSubmit. - There is no approval helper.
can_use_toolis HITL confirmation, not policy. - Don’t raise from
guard_toolto signal a denial. Return JSON in content withis_error: True. - Don’t depend on
structuredContent. Python does not forward it. - Don’t apply
guard_toolandPreToolUseto the same authored tool. - Don’t mint a session ID. If the caller did not pass a UUID
session_id, leave the call uncorrelated. - Don’t wrap these tools with
@arcjet/guard/claude-agent-sdk/v0.
Common patterns
Section titled “Common patterns”Rate limit a tool per trusted key
Section titled “Rate limit a tool per trusted key”Key the bucket on a trusted identifier you own. Don’t key it on free-text user input.
lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)
guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", rules=[lookup_limit(key="orders", requested=1)],)Scan a free-text note for sensitive information
Section titled “Scan a free-text note for sensitive information”Scan a note, reason, or body from the parsed tool arguments. An
opaque order_id is not a PII sample, so don’t pass it to
LocalDetectSensitiveInfo.
from arcjet.guard import LocalDetectSensitiveInfo, TokenBucketfrom arcjet.guard.claude_agent_sdk import guard_tool
detect_pii = LocalDetectSensitiveInfo()
guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", rules=lambda arguments: [ lookup_limit(key="orders", requested=1), detect_pii(arguments["note"]), ],)Correlate the run
Section titled “Correlate the run”Pass the caller-owned UUID on ClaudeAgentOptions(session_id=...),
guard_hooks, and guard_tool. Don’t mint an ID.