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.

Vercel AI SDK, LangChain, 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! });

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

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.

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

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:

  1. options.sessionId must be a UUID. Anything else exits the CLI with Error: Invalid session ID. Must be a valid UUID.
  2. A session ID can only be created once. Passing the same ID to a second query() exits with Error: Session ID <id> is already in use. Continue an existing conversation with options.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.

  • There is no guardInbound. Screen prompt injection on guardHooks({ inbound }) in UserPromptSubmit.
  • There is no guardCanUseTool. canUseTool is not a policy gate.
  • There is no guardApproval.
  • Don’t call createAgentContext inside a Claude callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t apply guardTool and PreToolUse to the same authored tool.
  • 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: guardHooks with inbound.rules calling detectPromptInjection()(prompt).
  • Rate limit plus PII on an authored tool: guardTool with tokenBucket and localDetectSensitiveInfo() on a free-text note.
  • Built-ins and MCP tools: PreToolUse through guardHooks, and list every guardTool wrapper in exclude.