Skip to content

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 the Guard SDK and the Claude Agent SDK:

Terminal window
npm install @arcjet/guard @anthropic-ai/claude-agent-sdk

Import 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! });

Pick the surface that matches what you hold when the effect runs.

The integration exposes three surfaces:

  • guardTool(client, toolDefinition, policy) wraps an authored tool(). On DENY it returns 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.
  • guardHooks() screens inbound text on UserPromptSubmit, denies unwrapped built-in and MCP tools on PreToolUse, and captures results on PostToolUse. Capture cannot undo a tool that already ran.
  • claudeAgentContext() reads session_id from hook input or options.sessionId. It never mints an ID. Subagent agent_id is 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:

Terminal window
mkdir -p ~/.claude/skills
cp -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.

OptionHelpersDescription
actionguardTool, guardHooksGuard label and capture name. A string, or for hooks a function of { toolName }.
rulesguardTool, guardHooksSDK rules, or a function of the tool input or { toolName }. Omit to submit none.
inboundguardHooksPolicy for UserPromptSubmit. Uses { prompt } in rules.
excludeguardHooksTools already wrapped with guardTool. Pass { server, name } for authored MCP tools or a bare string for a built-in such as "Bash".
sessionIdguardHooksFallback correlation ID. Must be a UUID if you also pass it to query().
onGuardErrorBoth"deny" (default) or "allow".

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;
}

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),
],
},
);

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.

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.

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.

For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.

Two Claude Agent SDK constraints decide what you can pass, and neither is Arcjet’s:

  1. A session ID must be a UUID. Anything else exits the CLI with an invalid session ID error.
  2. 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.

  • 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 PreToolUse to the same tool.
  • Don’t mix the JavaScript and Python adapters on the same tool.
  • There is no guardInbound, no guardCanUseTool, and no guardApproval.
  • Don’t call createAgentContext inside a Claude callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t throw from guardTool to signal a denial. Return the CallToolResult with isError: true.
  • Don’t import @arcjet/guard/claude-agent-sdk. The path is @arcjet/guard/claude-agent-sdk/v0.
  • 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: PreToolUse through the hooks helper, with every authored-tool wrapper listed in exclude.

Key a rate limit bucket on a trusted identifier you own. Don’t key it on free-text user input.