Skip to content

Claude Managed Agents agent guard

Claude Managed Agents is Anthropic’s hosted agent harness. Anthropic runs the built-in toolset (bash, files, and similar) in its environment. Arcjet Guard sits at the boundaries your application still holds: inbound user.message before you send it, and custom tools on agent.custom_tool_use before your app executes them.

What is Arcjet? Arcjet is the AI agent runtime security platform. Discover the agents running in your organization, enforce policy across every action, prompt, and tool call, and keep the evidence to prove what happened. Detect prompt injection, authorize agent tool calls, redact PII, and block bots and abuse.

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 adapter is not the Claude Agent SDK. The Agent SDK is a local query() loop with PreToolUse hooks. For that product, see Claude Agent SDK agent guard.

Vercel AI SDK, LangChain, CrewAI, Eve, Mastra, and other wrappers are on Framework integrations.

Anthropic owns the session and executes built-in tools. The agent toolset defaults to always_allow. Your application does not get a pre-exec hook for bash, files, or other built-ins before they run.

The gates you can enforce are the following:

  • Inbound user.message: Screen the prompt with guardEvents / guard_events before you call events.send. On DENY, don’t send the event. The model never sees the prompt.
  • Custom tools on agent.custom_tool_use: Wrap the handler your app runs with guardCustomTool / guard_custom_tool. On DENY, the handler does not run. Return the denial as user.custom_tool_result with is_error set. is_error is on the events schema. Don’t throw.

always_ask plus user.tool_confirmation is opt-in. It is not the default. It is human-in-the-loop (HITL) confirmation, not policy. Don’t wrap confirmation as Guard.

For MCP, Anthropic is the MCP client. Put Guard on MCP servers you host. You cannot intercept an MCP call that Anthropic executes against a server you don’t run.

The peer packages are @anthropic-ai/sdk (>=0.86.0 <1, JavaScript) and anthropic>=0.92.0,<2 (Python). Don’t install claude-agent-sdk or @anthropic-ai/claude-agent-sdk for this adapter.

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

@anthropic-ai/sdk (>=0.86.0 <1) is an optional peer, not a dependency of @arcjet/guard. If your project already has it, install @arcjet/guard on its own so your pins don’t move. The peer is @anthropic-ai/sdk, not @anthropic-ai/claude-agent-sdk.

Import helpers from the versioned path @arcjet/guard/claude-managed-agents/v0. There is no unversioned alias. @arcjet/guard/claude-managed-agents does not resolve. The Managed Agents API is pre-1.0, so the segment is v0. The integration requires Node.js >=22.21.0 <23 || >=24.5.0.

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:

You haveJavaScriptPythonBlocks a call?
Inbound user text, before events.sendguardEventsguard_eventsYes
A custom tool your app executesguardCustomToolguard_custom_toolYes
A caller-owned correlation IDclaudeManagedAgentsContextclaude_managed_agents_contextNo

guardEvents / guard_events screens inbound text on user.message before you send it. There is no guardInbound and no UserPromptSubmit hook. Anthropic does not expose a local pre-prompt hook on this product.

guardCustomTool / guard_custom_tool wraps the handler you run when the session emits agent.custom_tool_use. On DENY, or when Guard cannot be evaluated and onGuardError / on_guard_error is "deny", the handler does not run. Return the denial as user.custom_tool_result with is_error set. is_error is on the events schema. Don’t invent a second field. Don’t throw. A throw is a raw exception.

claudeManagedAgentsContext / claude_managed_agents_context reads a correlation ID your application owns, such as a conversation ID. It never mints an ID and it never calls createAgentContext. Don’t pass the id Anthropic returned from sessions.create: Anthropic session and event IDs are dropped, because they aren’t IDs you created. If you omit a correlation ID, the call is uncorrelated rather than joined to a generated ID.

There is no guardTool, guardHooks, PreToolUse, or canUseTool on this adapter. Those names belong to the Claude Agent SDK. Don’t wrap these tools with @arcjet/guard/claude-agent-sdk/v0 or arcjet.guard.claude_agent_sdk.

guardCustomTool(client, call, policy) takes the call as { event, execute, send } and this policy:

OptionDescription
actionGuard label and capture name. Use resource.verb in the past tense, such as email.sent.
rulesSDK rules, or a function of the tool input. Omit to submit none.
actorTrusted identity, or a function resolved per call. Take it from authenticated application state, never from a model-produced argument.
inputsNamed values built with policyInput.server.* or policyInput.local.*, or a function resolved per call.
metadataMerged over the context’s, as an object or a function of the input.
contextCorrelation from claudeManagedAgentsContext.
onGuardError"deny" (default) or "allow".

It answers { allowed: true, output }, or { allowed: false, result } after it has already sent the denial through send.

guardEvents(client, policy, send) takes this policy and a send callback it invokes only on allow:

OptionDescription
eventsThe events you would send. Only user.message is screened.
inbound.actionGuard label and capture name. Defaults to "message.received".
inbound.rulesSDK rules, or a function of { text, events }. Omit to submit none.
inbound.actorTrusted identity, or a function of { text, events }. Take it from authenticated application state, never from the message text.
inbound.inputsNamed values built with policyInput.server.* or policyInput.local.*, or a function of { text, events }.
inbound.onGuardError"deny" (default) or "allow".
contextCorrelation from claudeManagedAgentsContext.
metadataMerged over the context’s.

It answers { allowed: true, sent }, or { allowed: false, outcome, message } without calling send.

There is no guardInbound and no UserPromptSubmit. Screen prompt injection on guardEvents / guard_events inbound before you send user.message. This is the only place a turn can be declined before the model sees the prompt.

On DENY, don’t send the event. The model never sees the prompt.

Helpers default to onGuardError: "deny" / on_guard_error="deny". "allow" is a legitimate choice on inbound, because failing closed there stops the agent answering during an outage.

Correlate on an ID your application owns, such as a conversation ID. Don’t mint one per call, and don’t reuse the Anthropic session id. If you omit it, the call is uncorrelated.

import Anthropic from "@anthropic-ai/sdk";
import { detectPromptInjection } from "@arcjet/guard";
import {
claudeManagedAgentsContext,
guardEvents,
} from "@arcjet/guard/claude-managed-agents/v0";
import { arcjet } from "./arcjet.js";
const client = new Anthropic();
export async function sendTurn(
sessionId: string,
conversationId: string,
userText: string,
) {
const inbound = await guardEvents(
arcjet,
{
events: [
{ type: "user.message", content: [{ type: "text", text: userText }] },
],
inbound: {
action: "message.received",
rules: ({ text }) => [detectPromptInjection()(text)],
},
context: claudeManagedAgentsContext({ correlationId: conversationId }),
},
(body) => client.beta.sessions.events.send(sessionId, body),
);
if (!inbound.allowed) {
return inbound.message;
}
}

sessionId is the id returned by client.beta.sessions.create. conversationId is an ID your application owns, and it is what Arcjet correlates on.

Gate custom tools on agent.custom_tool_use

Section titled “Gate custom tools on agent.custom_tool_use”

Your application executes custom tools. Anthropic does not. Permission policies do not apply to them. When the session emits agent.custom_tool_use, wrap the handler you are about to run.

On DENY the handler does not run. Send user.custom_tool_result with is_error set and the denial payload in the result text so the model can inspect it. is_error is on the events schema. Don’t throw. A throw is a raw exception.

Scan free-text args (a note, reason, or body). An opaque orderId / order_id does not trip email, phone, card, or IP detection, so don’t pass it to localDetectSensitiveInfo / LocalDetectSensitiveInfo. That helper runs on a local ML model backend.

import Anthropic from "@anthropic-ai/sdk";
import {
claudeManagedAgentsContext,
guardCustomTool,
} from "@arcjet/guard/claude-managed-agents/v0";
import type { AgentCustomToolUseEvent } from "@arcjet/guard/claude-managed-agents/v0";
import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const client = new Anthropic();
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
export function lookupOrder(
event: AgentCustomToolUseEvent,
sessionId: string,
conversationId: string,
) {
return guardCustomTool(
arcjet,
{
event,
execute: async (input) => ({
orderId: String(input.orderId),
status: `shipped (${String(input.note)})`,
}),
send: (result) =>
client.beta.sessions.events.send(sessionId, { events: [result] }),
},
{
action: "order.looked-up",
rules: (input) => [
lookupLimit({ key: String(input.orderId), requested: 1 }),
detectPii(String(input.note)),
],
context: claudeManagedAgentsContext({ correlationId: conversationId }),
},
);
}

Call lookupOrder from your agent.custom_tool_use handler. On deny it sends the error result itself, so only send user.custom_tool_result when the answer is allowed, with custom_tool_use_id set to the triggering event id.

Built-in tools (agent.tool_use) already ran when you see the event, unless you opted into always_ask. That confirmation path is HITL, not a Guard deny.

The agent toolset defaults to always_allow. Built-in bash and file tools run in Anthropic’s environment with no customer pre-exec.

always_ask pauses the session for user.tool_confirmation. That callback is HITL confirmation, not policy. Allowed-tool lists and confirmation results can skip or approve a built-in after the fact. There is no guardApproval and no canUseTool on this adapter.

Use guardCustomTool / guard_custom_tool for tools your app executes. Don’t put Arcjet policy on user.tool_confirmation.

Anthropic is the MCP client. When a session calls an MCP tool, Anthropic connects to the server. You don’t get a local PreToolUse hook.

If you host the MCP server, put Guard inside that server’s tool handlers. If Anthropic reaches a server you don’t run, you cannot deny the call from this adapter.

Claude Managed Agents helpers default to onGuardError: "deny" / on_guard_error="deny". If Guard cannot be evaluated, inbound user.message is not sent and the custom-tool handler does not run.

Set onGuardError: "allow" or on_guard_error="allow" only when you can accept running the action without a complete security decision. "allow" is a legitimate choice on inbound user.message because failing closed there stops the agent answering during an outage.

A DENY conclusion always blocks, regardless of onGuardError / on_guard_error.

The core guard() call still fails open. The wrappers that sit around an effect fail closed.

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

claudeManagedAgentsContext / claude_managed_agents_context reads a correlation ID your application owns, such as the conversation ID you already store against the user. It never mints an ID and it never reads a generated request or trace ID. If you omit one, the call is uncorrelated rather than joined to a generated ID.

Don’t pass an Anthropic session or event ID (sesn_…, sevt_…, or an agent.custom_tool_use id). Those are Anthropic’s identifiers rather than ones you created, so the helper drops them. Anthropic’s session ID still addresses the session you send events to, which is a separate job from correlation.

const session = await client.beta.sessions.create({
agent: AGENT_ID,
environment_id: ENVIRONMENT_ID,
});
// session.id addresses the session. conversationId correlates the
// decisions, and it is yours.
const context = claudeManagedAgentsContext({
correlationId: conversationId,
});
const inbound = await guardEvents(
arcjet,
{
events: [
{ type: "user.message", content: [{ type: "text", text: userText }] },
],
inbound: {
action: "message.received",
rules: ({ text }) => [detectPromptInjection()(text)],
},
context,
},
(body) => client.beta.sessions.events.send(session.id, body),
);

Replace the following:

  • AGENT_ID: the agent id returned by agents.create.
  • ENVIRONMENT_ID: the environment id returned by environments.create.
  • conversationId: an ID your application owns.
  • There is no guardInbound. Screen prompt injection on guardEvents / guard_events inbound before user.message.
  • There is no guardApproval and no canUseTool. always_ask plus user.tool_confirmation is HITL confirmation, not policy.
  • There is no PreToolUse and no query(). Those belong to the Claude Agent SDK.
  • Don’t expect a customer pre-exec hook for built-in bash or files. The default is always_allow.
  • Don’t throw from guardCustomTool / guard_custom_tool to signal a denial. Return user.custom_tool_result with is_error set.
  • Don’t mint a correlation ID, and don’t reuse the Anthropic session id as one. Pass an ID your application owns, or leave the call uncorrelated.
  • Don’t wrap these tools with @arcjet/guard/claude-agent-sdk/v0 or arcjet.guard.claude_agent_sdk.
  • Don’t wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t import @arcjet/guard/claude-managed-agents. The path is @arcjet/guard/claude-managed-agents/v0.

A policy reads the actor and the typed inputs the call carries. Both adapters map them.

guardCustomTool takes actor and inputs, each as a value or a function resolved per call. A resolver receives the parsed tool input, then the agent.custom_tool_use event for a hosted tool or the second context argument for a self-hosted run.

const sendEmail = guardCustomTool(arcjet, sendEmailTool, {
action: "email.sent",
actor: currentUser.id,
inputs: ({ recipient, body }) => ({
recipient: policyInput.server.string(recipient),
allowed_recipients: policyInput.server.stringList(
currentUser.allowedRecipients,
),
body: policyInput.local.string(body),
}),
});

Build every input explicitly with policyInput.server.* or policyInput.local.*. Plain values are rejected, and the adapter never discovers arguments for you.

guardEvents takes them as inbound.actor and inbound.inputs. Its resolvers receive { text, events } for the message being sent.

The policy these calls feed declares recipient as a SERVER string, allowed_recipients as a SERVER string list, and body as a LOCAL string, then denies a recipient that isn’t on the list:

package arcjet.guard
import rego.v1
deny contains "external-recipient" if {
not input.values.recipient in input.values.allowed_recipients
}

Map only what a policy needs. Take the actor and any allow list from authenticated application state, never from the model’s arguments: a policy can be conditioned on the actor, so a model that controls it can leave its own policy scope.

For the names, kinds, and exposures a policy declares, see Policy contract. For more worked policies, see Policy examples.

  • Prompt injection before the model sees the prompt: guardEvents / guard_events with inbound rules calling detectPromptInjection()(prompt) / DetectPromptInjection().
  • Rate limit plus PII on a custom tool: guardCustomTool / guard_custom_tool with tokenBucket / TokenBucket and localDetectSensitiveInfo() / LocalDetectSensitiveInfo() on a free-text note.
  • MCP tools on a server you host: Guard inside that server’s handlers. Anthropic is the MCP client.