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.

Use protect() for HTTP routes. Use the Claude helpers on this page for authored tools, inbound prompts, and unwrapped built-in or MCP tools. Vercel AI SDK, LangChain, Eve, and Mastra 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! });

The integration exposes three surfaces:

  • guardTool(client, toolDefinition, policy) wraps an authored tool(). On DENY it returns a CallToolResult with isError: true. Do not throw.
  • 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 }) via UserPromptSubmit. There is no guardCanUseTool and no guardApproval.

Never call createAgentContext inside a Claude callback. Do not 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.

There is no guardInbound. Screen prompt injection on guardHooks({ inbound }) via 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 { guardHooks } from "@arcjet/guard/claude-agent-sdk/v0";
import { detectPromptInjection } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const sessionId = conversationId;
for await (const message of query({
prompt: userText,
options: {
sessionId,
hooks: guardHooks(arcjet, {
sessionId,
inbound: {
action: "message.received",
rules: ({ prompt }) => [detectPromptInjection()(prompt)],
},
}),
},
})) {
void message;
}

Claude’s canUseTool is skipped by allowedTools, allow rules, and bypassPermissions / acceptEdits. 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. Do not 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 a CallToolResult with isError: true. Scan free-text args (a note, reason, or body). An opaque orderId will not trip email / phone / card / IP, so do not 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),
],
},
);

PreToolUse is the only deny for unwrapped tools

Section titled “PreToolUse is the only deny for unwrapped tools”

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.

Do not 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 })],
inbound: {
action: "message.received",
rules: ({ prompt }) => [detectPromptInjection()(prompt)],
},
});

Pass hooks to query({ options.hooks }). Use this for tools you did not pass through guardTool.

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+).

Set options.sessionId on query() to a conversation identity you already have. 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.

const sessionId = conversationId;
for await (const message of query({
prompt: userText,
options: { sessionId, hooks: guardHooks(arcjet, { sessionId }) },
})) {
void message;
}
  • There is no guardInbound. Screen prompt injection on guardHooks({ inbound }) via UserPromptSubmit.
  • There is no guardCanUseTool. canUseTool is not a policy gate.
  • There is no guardApproval.
  • Do not call createAgentContext inside a Claude callback.
  • Do not also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Do not apply guardTool and PreToolUse to the same authored tool.
  • Do not import @arcjet/guard/claude-agent-sdk. The path is @arcjet/guard/claude-agent-sdk/v0.