Claude Agent SDK agent guard
Claude Agent SDK agents call authored tool handlers, built-in tools, and MCP tools. Arcjet Guard sits at those boundaries so a remote 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 page covers both adapters. The JavaScript adapter is
@arcjet/guard/claude-agent-sdk/v0. The Python adapter is
arcjet.guard.claude_agent_sdk. They are separate packages that gate the same
boundaries, so pick the tab for the language your agent runs in and don’t mix
the two.
Vercel AI SDK, LangChain, CrewAI, Eve, Mastra, and LangGraph wrappers are on Framework integrations.
Install
Section titled “Install”Install the Guard SDK and the Claude Agent SDK:
npm install @arcjet/guard @anthropic-ai/claude-agent-sdkImport helpers from the versioned path @arcjet/guard/claude-agent-sdk/v0.
There is no unversioned alias. @arcjet/guard/claude-agent-sdk does not
resolve. The Claude Agent SDK is pre-1.0, so the segment is v0.
@anthropic-ai/claude-agent-sdk is an optional peer (>=0.1.0 <1). The
integration requires Node.js 22 or later.
Launch one client at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });pip install "arcjet[claude-agent-sdk]"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.
The integration exposes three surfaces:
guardTool(client, toolDefinition, policy)wraps an authoredtool(). OnDENYit returns an MCPCallToolResultwithisError: trueand theArcjetDenialResultpayload onstructuredContent. Don’t throw. A throw is a raw exception. OmittingisErrorlooks like success.guardHooks()screens inbound text onUserPromptSubmit, denies unwrapped built-in and MCP tools onPreToolUse, and captures results onPostToolUse. Capture cannot undo a tool that already ran.claudeAgentContext()readssession_idfrom hook input oroptions.sessionId. It never mints an ID. Subagentagent_idis metadata only.
There is no guardInbound. Screen prompt injection on guardHooks({ inbound })
in UserPromptSubmit. There is no guardCanUseTool and no guardApproval.
Never call createAgentContext inside a Claude callback. Don’t also wrap
these tools with @arcjet/guard/vercel-ai/v7.
The Claude Agent SDK integration skill ships with @arcjet/guard. After
npm install, copy or symlink it into your coding agent’s skills directory:
mkdir -p ~/.claude/skillscp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-claude-agent-sdk ~/.claude/skills/Then ask the agent to add Arcjet Guard to this Claude Agent SDK project. In
Claude Code, run /integrate-arcjet-guard-claude-agent-sdk. The skill source is
integrate-arcjet-guard-claude-agent-sdk.
| 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”| Option | Helpers | Description |
|---|---|---|
action | guardTool, guardHooks | Guard label and capture name. A string, or for hooks a function of { toolName }. |
rules | guardTool, guardHooks | SDK rules, or a function of the tool input or { toolName }. Omit to submit none. |
inbound | guardHooks | Policy for UserPromptSubmit. Uses { prompt } in rules. |
exclude | guardHooks | Tools already wrapped with guardTool. Pass { server, name } for authored MCP tools or a bare string for a built-in such as "Bash". |
sessionId | guardHooks | Fallback correlation ID. Must be a UUID if you also pass it to query(). |
onGuardError | Both | "deny" (default) or "allow". |
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.
Screen inbound with UserPromptSubmit
Section titled “Screen inbound with UserPromptSubmit”There is no inbound helper. Screen prompt injection on the hooks inbound path
in UserPromptSubmit. This is the only place a turn can be declined before the
model sees the prompt.
On DENY, UserPromptSubmit returns a block decision and Claude Code erases
the prompt. The model never sees it.
Helpers fail closed by default. Failing open on inbound is a legitimate choice, because failing closed there stops the agent answering during an outage. Timeout already fail-closes the prompt (Claude Code v2.1.208+).
On DENY, UserPromptSubmit returns { decision: "block", reason }.
import { query } from "@anthropic-ai/claude-agent-sdk";import { randomUUID } from "node:crypto";import { guardHooks } from "@arcjet/guard/claude-agent-sdk/v0";import { detectPromptInjection } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
// `options.sessionId` must be a UUID and can only be created once, so mint one// per conversation and store it. For more information about session IDs, see// the Correlation section.const sessionId = conversationId ?? randomUUID();
for await (const message of query({ prompt: userText, options: { // First turn only. Later turns: `resume: sessionId`. sessionId, hooks: guardHooks(arcjet, { sessionId, inbound: { action: "message.received", rules: ({ prompt }) => [detectPromptInjection()(prompt)], }, }), },})) { void message;}On DENY, UserPromptSubmit returns {"decision": "block"}.
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”An authored tool handler is the local side effect this integration can stop. On
DENY the handler does not run and the model receives a denial result.
Scan free-text arguments (a note, reason, or body). An opaque order ID doesn’t trip email, phone, card, or IP detection, so don’t pass it to the local sensitive information helper. That helper runs on a local ML model backend.
The model receives an MCP CallToolResult with isError: true and the
ArcjetDenialResult payload on structuredContent. Don’t throw. A throw is a
raw exception. Omitting isError looks like success.
import { tool } from "@anthropic-ai/claude-agent-sdk";import { z } from "zod";import { guardTool } from "@arcjet/guard/claude-agent-sdk/v0";import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});// Factory then text – same shape as `detectPromptInjection()(text)`.const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});
export const lookupOrder = guardTool( arcjet, tool( "lookup_order", "Look up an order by ID", { orderId: z.string(), note: z.string(), }, async ({ orderId, note }) => ({ content: [{ type: "text", text: `${orderId}: shipped (${note})` }], }), ), { action: "order.looked-up", rules: (input) => [ lookupLimit({ key: input.orderId, requested: 1 }), detectPii(input.note), ], },);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.
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( deny=["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],)
@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 the
authored-tool wrapper are gated here. PreToolUse returns
permissionDecision: "deny". Timeout already fail-closes, so the tool does not
run.
Annotations (readOnlyHint) and sandbox settings are not enforcement.
PostToolUse is capture only and cannot undo a tool that already ran.
permissionDecision: "ask" is human-in-the-loop confirmation, not deny.
Don’t apply the authored-tool wrapper and PreToolUse to the same tool. That
double-calls the guard.
PreToolUse fires for every tool, and its input carries only a name, never
the Arcjet marker the authored-tool wrapper applies – so list your wrapped
tools in exclude or each one is guarded twice per invocation, costing two
round trips and two quota units.
Entries match the reported name exactly. An authored tool arrives as
mcp__<server>__<tool>, so pass a server and name pair 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 deliberately does not match
every server’s tool of that name: two servers can expose the same name with
only one of them wrapped, and a loose match would drop the gate on the
unprotected one. Excluding a tool stops the gate, not the PostToolUse
capture.
import { guardHooks } from "@arcjet/guard/claude-agent-sdk/v0";import { detectPromptInjection, tokenBucket } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({ bucket: "mcp-access", refillRate: 20, intervalSeconds: 60, maxTokens: 20,});
export const hooks = guardHooks(arcjet, { sessionId: conversationId, action: ({ toolName }) => `${toolName}.invoked`, rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })], // Tools already wrapped with `guardTool` guard themselves. exclude: [{ server: "support", name: "lookup_order" }], inbound: { action: "message.received", rules: ({ prompt }) => [detectPromptInjection()(prompt)], },});Pass hooks to query({ options.hooks }). Use this for tools you did not
pass through guardTool.
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.
Tool permission callbacks are not a policy gate
Section titled “Tool permission callbacks are not a policy gate”allowedTools, allow rules, and bypassPermissions / acceptEdits skip the
Claude permission callback. That is the same trap as Eve approval and Mastra
requireApproval: the callback looks like a gate, but the runtime can skip it.
There is no Arcjet wrapper for the permission callback, and there is no approval
helper. Don’t put Arcjet policy on a permission callback. Gate authored tools
with the authored-tool wrapper, and unwrapped ones with PreToolUse.
There is no guardCanUseTool. Don’t put Arcjet policy on canUseTool. Use
guardTool for authored tools or PreToolUse for unwrapped ones.
can_use_tool pauses the run for human-in-the-loop confirmation.
allowed_tools, allow rules, and bypassPermissions / acceptEdits
can skip that callback.
There is no guard_can_use_tool. 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”Claude Agent SDK helpers fail closed by default. If Guard cannot be evaluated,
the tool does not run and inbound UserPromptSubmit blocks the prompt. Failing
open is a legitimate choice on inbound UserPromptSubmit, because failing
closed there stops the agent answering during an outage. Timeout already
fail-closes the prompt (Claude Code v2.1.208+).
A DENY conclusion always blocks, whatever the fail behavior is set to.
The core guard call still fails open. It returns ALLOW and reports that it
failed open. The wrappers that sit around an effect fail closed.
guardTool and guardHooks default to onGuardError: "deny". Set
onGuardError: "allow" only when you can accept running the action without a
complete security decision.
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.
Set on_guard_error="allow" only when you can accept running the
action without a complete security decision. The helpers reject any
other value.
has_failed_open() returns True when the core guard() call failed open.
For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.
Correlation
Section titled “Correlation”Two Claude Agent SDK constraints decide what you can pass, and neither is Arcjet’s:
- A session ID must be a UUID. Anything else exits the CLI with an invalid session ID error.
- A session ID can only be created once. Passing the same ID to a second query exits with an already-in-use error. Continue an existing conversation by resuming it.
So mint one UUID per conversation and resume it on later turns. That is also
what keeps a multi-turn conversation on a single Sequence: because the context
helper prefers the hook’s session_id, a fresh UUID per turn splits
correlation silently instead of erroring.
The context helper never mints an ID. If it receives no valid 1-256
printable-ASCII string, the call is uncorrelated rather than joined to a
generated ID. Subagent agent_id is metadata only.
claudeAgentContext reads hook session_id first, then options.sessionId. It
never calls createAgentContext.
import { randomUUID } from "node:crypto";
// Store this with the conversation. Don't generate one per turn.const sessionId = conversationId ?? randomUUID();
async function turn(userText: string, firstTurn: boolean) { for await (const message of query({ prompt: userText, options: { ...(firstTurn ? { sessionId } : { resume: sessionId }), hooks: guardHooks(arcjet, { sessionId }), }, })) { void message; }}Pass sessionId to guardHooks either way: a resumed turn reports the same id
on hook input, and the policy value is the fallback for when it does not. A
single query() with a streaming-input prompt is the other supported multi-turn
shape, and needs no resume.
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.
trace_id is never read.
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 the hooks inbound path
in
UserPromptSubmit. - There is no approval helper. A tool permission callback is confirmation, not policy.
- Don’t signal a denial by throwing or raising. Return the tool result with the error flag set.
- Don’t apply the authored-tool wrapper and
PreToolUseto the same tool. - Don’t mix the JavaScript and Python adapters on the same tool.
- There is no
guardInbound, noguardCanUseTool, and noguardApproval. - Don’t call
createAgentContextinside a Claude callback. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Don’t throw from
guardToolto signal a denial. Return theCallToolResultwithisError: true. - Don’t import
@arcjet/guard/claude-agent-sdk. The path is@arcjet/guard/claude-agent-sdk/v0.
- There is no
guard_inbound, noguard_can_use_tool, and noguard_approval. - 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 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”- Prompt injection before the model sees the prompt: the hooks helper with an inbound policy that calls the prompt injection rule.
- Rate limit plus PII on an authored tool: the authored-tool wrapper with a token bucket and the local sensitive information rule on a free-text note.
- Built-ins and MCP tools:
PreToolUsethrough the hooks helper, with every authored-tool wrapper listed inexclude.
Key a rate limit bucket on a trusted identifier you own. Don’t key it on free-text user input.
Related
Section titled “Related”- Framework integrations
- Claude Managed Agents agent guard
- Vercel AI SDK agent guard
- LangChain agent guard
- CrewAI agent guard
- Mastra agent guard
- Vercel Eve agent guard
- LangGraph agent guard
- Python Guard SDK reference
- Agent guards
- JavaScript example: arcjet/examples#193
(
claude-agent) - Python example: examples/fastapi-claude-agent-sdk-guard
- Python adapter:
9ea0b06a