Integrate Arcjet Guard into a Claude Agent SDK agent
@arcjet/guard's Claude Agent SDK v0 namespace wraps the agent's existing
Arcjet client. It never talks to the Arcjet API itself. Three surfaces, one
decision rule:
- An authored tool (
tool()+createSdkMcpServer()) →guardTool(). DENY is aCallToolResultwithisError: true. Do not throw. - Inbound text →
guardHooks()UserPromptSubmit. DENY is{ decision: "block" }. Timeout already fail-closes the prompt (Claude Code v2.1.208+). - Built-ins / unwrapped MCP →
guardHooks()PreToolUsewithpermissionDecision: "deny". Timeout already fail-closes (the tool does not run).PostToolUseis capture only. - Correlation →
claudeAgentContext()readssession_idfrom hook input oroptions.sessionId. Subagents haveagent_id(metadata only). It never mints a new id.options.sessionIdmust be a UUID and can only be created once — see Step 5.
Screen inbound with UserPromptSubmit
This is the only place a turn can be declined before the model sees the
prompt. There is no guardInbound. Put detectPromptInjection on
guardHooks({ inbound }). A DENY returns { decision: "block", reason }
and Claude Code erases the prompt.
canUseTool is not a policy gate
Claude's docs say canUseTool is skipped by allowedTools, allow rules,
and bypassPermissions / acceptEdits. There is no guardCanUseTool.
Do not put Arcjet policy on canUseTool. Same trap as Google ADK
requireConfirmation: after a human yes, Guard still runs.
PreToolUse is the only deny for unwrapped tools
Built-ins (Bash, Write, …) and MCP tools you did not pass through
guardTool are gated here. Annotations (readOnlyHint, …) and sandbox
settings are not enforcement. PostToolUse cannot undo a tool that already
ran.
Questions to ask the human first
Ask only what you cannot infer from the code; suggest defaults.
- Which tools are risky (external side effects, irreversible, spends
money, sends messages)? Those get
guardTool. Built-ins and unwrapped MCP getguardHooksPreToolUse. - What limits? (e.g. "10 lookups/min per order" →
tokenBucket.) - Who is the user for metadata — an opaque user/tenant ID (never PII)?
Default: none. Pass it via
metadataon the policy. Session id is the correlation id, not the user. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inboundUserPromptSubmit: failing closed there means the agent stops answering for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.
The seven things readers get wrong
- There is no
guardInbound. Screen prompt injection onguardHooks({ inbound })viaUserPromptSubmit. canUseToolis not a policy gate. It is skipped byallowedTools, allow rules, andbypassPermissions/acceptEdits. UseguardToolorPreToolUse.- The import path is versioned and there is no alias.
@arcjet/guard/claude-agent-sdk/v0.@arcjet/guard/claude-agent-sdkdoes not resolve. options.sessionIdis a UUID, and only once. A non-UUID exits with "Invalid session ID"; reusing one exits with "already in use". Mint a UUID for the conversation andresumeit on later turns.- Correlation is read, never minted. Do not call
createAgentContextinside a Claude callback — that generates a second id and splits the Sequence.claudeAgentContextreadssession_id/options.sessionIdand omitscorrelationIdwhen neither is a valid id. Subagentagent_idis metadata, not the correlation id. - Do not double-wrap with
@arcjet/guard/vercel-ai/v7,@arcjet/guard/agents, or@arcjet/guard/claude-managed-agents/v0. Claude tools aretool(), not AI SDKtool(), and this is not hosted Claude Managed Agents.guardToolthrows if the tool already carries the Arcjet protection brand. ApplyingguardToolandguardHooksPreToolUse to the same authored tool double-calls the guard. - A denial from
guardToolis aCallToolResultwithisError: true, not a throw. IfonDenythrows, the handler still does not run and the model still receives the default denial result.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @anthropic-ai/claude-agent-sdk
(optional peer, needed for @arcjet/guard/claude-agent-sdk/v0). Always
use the versioned path: @arcjet/guard/claude-agent-sdk/v0 resolves;
@arcjet/guard/claude-agent-sdk throws ERR_PACKAGE_PATH_NOT_EXPORTED.
npm install @arcjet/guard @anthropic-ai/claude-agent-sdk
If the agent has no guard client yet, launch one once at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
Step 2: Gate authored tools
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/claude-agent-sdk/v0";
import { tokenBucket, localDetectSensitiveInfo, policyInput } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
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",
actor: userId,
inputs: (input) => ({
orderId: policyInput.server.string(input.orderId),
}),
rules: (input) => [lookupLimit({ key: input.orderId, requested: 1 }), detectPii(input.note)],
},
);
- Omit
rulesto submit none. The guard call still happens. - Optional
actorandinputs(static, or a resolver over this adapter's native call — parsed input plus trusted runtime/context) are forwarded on the guard call so a remote policy that declares those names can evaluate. Build each input withpolicyInput. - On DENY the tool's handler never runs. The model receives
{ content, structuredContent: { arcjetDenied, reason, message, retryable }, isError: true }. - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable. - Pass the same
sessionIdyou givequery({ options.sessionId })on the policy when the handlerextradoes not carrysession_id.
Step 3: Screen inbound with UserPromptSubmit
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;
}
- On DENY,
UserPromptSubmitreturns{ decision: "block", reason }and the prompt is erased. The model never sees it. - Default
onGuardError: "deny"— if the guard cannot be evaluated, the prompt is blocked. Use"allow"oninboundwhen the human cost of rejecting a legitimate message exceeds the security cost of an outage.
Step 4: Gate tools you did not wrap
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 that already guard themselves via `guardTool`. Without this they
// are guarded twice per invocation.
exclude: [{ server: "support", name: "lookup_order" }],
inbound: {
action: "message.received",
rules: ({ prompt }) => [detectPromptInjection()(prompt)],
},
});
Pass hooks to query({ options.hooks }). PreToolUse returns
permissionDecision: "deny" so Bash / Write / unwrapped MCP never
execute. PostToolUse is observe-only.
Use this for tools you did not pass through guardTool. PreToolUse
fires for every tool and the hook input carries only a name, never the
Arcjet brand guardTool applies — so list your wrapped tools in
exclude, or each one costs two guard calls and two quota units per
invocation.
Entries match the reported name exactly. A bare string matches only that
name, which is what you want for a built-in like "Bash". An authored tool
arrives as mcp__<server>__<tool>, so exclude it with { server, name } —
or the full reported name — and never with the bare authored name: two
servers can expose the same tool name with only one of them wrapped, so a
loose match would drop the gate on the unprotected one. Excluding a tool
stops the gate, not the PostToolUse capture.
Step 5: Correlation
Two Claude CLI constraints decide the shape of this, 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 the conversation withoptions.resumeinstead.
So the conversation identity is a UUID, minted once, and every later turn
resumes it. That is also the only shape that keeps a multi-turn
conversation on one Sequence: claudeAgentContext reads the hook's
session_id first, so a fresh UUID per turn silently splits correlation
instead of erroring.
import { randomUUID } from "node:crypto";
// Store this with the conversation; do not 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: on a resumed turn the hook
input carries the same id, and the policy value is the fallback when it
does not. If neither is a valid 1–256 printable-ASCII string, the call is
uncorrelated rather than joined to a generated id nobody has. Subagent
agent_id is recorded as claude.agent metadata only.
A single query() with a streaming-input prompt is the other supported
multi-turn shape, and needs no resume.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI, a tool deny, PII on args, a rate limit, a built-in deny (Bash / Write), and fail-closed (an unreachable guard).
- Confirm a wrapped tool produces one guard decision per invocation,
not two — a second decision under the
PreToolUseaction means a missingexcludeentry. - Confirm in the Arcjet dashboard that decisions share the session id as their correlation id.
- Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo belongs in
arcjet/examples
(follow-up; do not add an example under examples/ in the JS SDK repo).
Note: capture events are fire-and-forget and batched, so events can lag the decisions they accompany by a few seconds. A dropped event is diagnosed, never thrown.