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.
Vercel AI SDK, LangChain, 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! });Helpers
Section titled “Helpers”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.
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". |
Screen inbound with UserPromptSubmit
Section titled “Screen inbound with UserPromptSubmit”There is no guardInbound. Screen prompt injection on guardHooks({ 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", reason } and
Claude Code erases the prompt. The model never sees it.
Helpers default to onGuardError: "deny". "allow" is a legitimate choice on
inbound, because failing closed there stops the agent answering during an
outage. Timeout already fail-closes the prompt (Claude Code v2.1.208+).
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;}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
Claude’s canUseTool. 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 guardCanUseTool. Don’t put Arcjet policy on canUseTool. Use
guardTool for authored tools or PreToolUse for unwrapped ones.
On DENY the tool’s handler never runs. 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. Scan free-text args (a note, reason, or body).
An opaque orderId doesn’t trip email, phone, card, or IP detection, so
don’t pass it to localDetectSensitiveInfo. That helper runs on a local ML
model backend.
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();
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), ], },);Deny unwrapped tools with PreToolUse
Section titled “Deny unwrapped tools with PreToolUse”Built-ins (Bash, Write, …) and MCP tools not passed through guardTool 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.
Don’t apply guardTool and PreToolUse to the same authored tool. That
double-calls the guard.
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.
PreToolUse fires for every tool and its input carries only a name, never
the Arcjet marker guardTool 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 { 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 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.
Fail-closed default
Section titled “Fail-closed default”Claude Agent SDK helpers default to onGuardError: "deny". If Guard cannot be
evaluated, the tool does not run and inbound UserPromptSubmit blocks the
prompt. "allow" 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+).
Correlation
Section titled “Correlation”claudeAgentContext reads hook session_id first, then options.sessionId. It
never calls createAgentContext. If neither is a valid 1-256 printable-ASCII
string, the call is uncorrelated rather than joined to a generated ID. Subagent
agent_id is metadata only.
Two Claude Agent SDK constraints decide what you can pass, and neither is Arcjet’s:
options.sessionIdmust be a UUID. Anything else exits the CLI withError: Invalid session ID. Must be a valid UUID.- A session ID can only be created once. Passing the same ID to a second
query()exits withError: Session ID <id> is already in use.Continue an existing conversation withoptions.resume.
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
claudeAgentContext prefers the hook’s session_id, a fresh UUID per turn
splits correlation silently instead of erroring.
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.
What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection onguardHooks({ inbound })inUserPromptSubmit. - There is no
guardCanUseTool.canUseToolis not a policy gate. - There is no
guardApproval. - Don’t call
createAgentContextinside a Claude callback. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Don’t apply
guardToolandPreToolUseto the same authored tool. - 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.
Common patterns
Section titled “Common patterns”- Prompt injection before the model sees the prompt:
guardHookswithinbound.rulescallingdetectPromptInjection()(prompt). - Rate limit plus PII on an authored tool:
guardToolwithtokenBucketandlocalDetectSensitiveInfo()on a free-textnote. - Built-ins and MCP tools:
PreToolUsethroughguardHooks, and list everyguardToolwrapper inexclude.