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.
Hosted harness
Section titled “Hosted harness”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 withguardEvents/guard_eventsbefore you callevents.send. OnDENY, don’t send the event. The model never sees the prompt. - Custom tools on
agent.custom_tool_use: Wrap the handler your app runs withguardCustomTool/guard_custom_tool. OnDENY, the handler does not run. Return the denial asuser.custom_tool_resultwithis_errorset.is_erroris 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.
Install
Section titled “Install”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! });pip install "arcjet[claude-managed-agents]"The Python adapter requires arcjet 1.1.0 or later.
arcjet[claude-managed-agents] depends on anthropic>=0.92.0,<2.
CPython 3.10 or later. The peer is anthropic, not
claude-agent-sdk.
Import helpers from arcjet.guard.claude_managed_agents:
from arcjet.guard.claude_managed_agents import ( claude_managed_agents_context, guard_custom_tool, guard_events,)Launch one client at module scope:
from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)Use launch_arcjet_sync with Flask, Django, or other sync code.
Helpers
Section titled “Helpers”Pick the surface that matches what you hold when the effect runs:
| You have | JavaScript | Python | Blocks a call? |
|---|---|---|---|
Inbound user text, before events.send | guardEvents | guard_events | Yes |
| A custom tool your app executes | guardCustomTool | guard_custom_tool | Yes |
| A caller-owned correlation ID | claudeManagedAgentsContext | claude_managed_agents_context | No |
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.
Helper options
Section titled “Helper options”guardCustomTool(client, call, policy) takes the call as
{ event, execute, send } and this policy:
| Option | Description |
|---|---|
action | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
rules | SDK rules, or a function of the tool input. Omit to submit none. |
actor | Trusted identity, or a function resolved per call. Take it from authenticated application state, never from a model-produced argument. |
inputs | Named values built with policyInput.server.* or policyInput.local.*, or a function resolved per call. |
metadata | Merged over the context’s, as an object or a function of the input. |
context | Correlation 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:
| Option | Description |
|---|---|
events | The events you would send. Only user.message is screened. |
inbound.action | Guard label and capture name. Defaults to "message.received". |
inbound.rules | SDK rules, or a function of { text, events }. Omit to submit none. |
inbound.actor | Trusted identity, or a function of { text, events }. Take it from authenticated application state, never from the message text. |
inbound.inputs | Named values built with policyInput.server.* or policyInput.local.*, or a function of { text, events }. |
inbound.onGuardError | "deny" (default) or "allow". |
context | Correlation from claudeManagedAgentsContext. |
metadata | Merged over the context’s. |
It answers { allowed: true, sent }, or
{ allowed: false, outcome, message } without calling send.
guard_custom_tool accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
run | One of | Hosted custom-tool body. Called with the agent.custom_tool_use event. |
tool | One of | Self-hosted worker tool with a callable call, such as a @beta_tool, @beta_async_tool, or BetaFunctionTool. |
actor | No | Who is acting, or a callable of the tool arguments. |
inputs | No | Typed remote-policy inputs, or a callable of the tool arguments. |
rules | No | Bound SDK rule inputs, or a callable of the tool arguments. Omit to submit none. |
metadata | No | Capture metadata, or a callable of the tool arguments. |
session_id | No | Correlation ID your application owns. Never minted, and never an Anthropic session or event ID. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
Pass run for the hosted path. The handler it returns is called as
await handler(event, send=..., anthropic_session_id=...), where
anthropic_session_id is the Anthropic ses_... session the denial is
posted to. It is separate from the wrap-time session_id, which is your
own correlation ID and is never read off the Anthropic value. On DENY
it sends user.custom_tool_result itself and answers None, so don’t
send a second result. On ALLOW it answers what run returned, and you
send the success result.
guard_events wraps sessions.events.send:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
send | Yes | client.beta.sessions.events.send, or another callable with the same events= / initial_events= keywords. |
action | Yes | Guard label and capture name, such as message.received. |
actor | No | Who is acting, or a callable of the message arguments. |
inputs | No | Typed remote-policy inputs, or a callable of the message arguments. |
rules | No | Bound SDK rule inputs, or a callable of {"prompt": ..., "content": ..., "type": ...}. Omit to submit none. |
metadata | No | Capture metadata, or a callable of the message arguments. |
session_id | No | Correlation ID your application owns. Never minted, and never an Anthropic session or event ID. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
There is no inbound option in Python and no inbound helper: action
and rules sit at the top level, and the returned callable replaces
send. On DENY it raises ArcjetDeniedError and never calls the real
send. Pass an async client, because a blocking one makes the wrapper
synchronous and it can’t be awaited from a coroutine.
Screen inbound user.message
Section titled “Screen inbound user.message”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.
from anthropic import AsyncAnthropicfrom arcjet.guard import ( ArcjetDeniedError, DetectPromptInjection, launch_arcjet,)from arcjet.guard.claude_managed_agents import guard_events
client = AsyncAnthropic()arcjet = launch_arcjet(key=ARCJET_KEY)inbound = DetectPromptInjection()
send = guard_events( guard=arcjet, send=client.beta.sessions.events.send, action="message.received", session_id=CONVERSATION_ID, rules=lambda arguments: [inbound(arguments["prompt"])],)
async def send_turn(session_id: str, user_text: str) -> bool: try: await send( session_id, events=[ { "type": "user.message", "content": [{"type": "text", "text": user_text}], } ], ) except ArcjetDeniedError: return False return Truesession_id is the id returned by client.beta.sessions.create.
CONVERSATION_ID 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.
from arcjet.guard import ( LocalDetectSensitiveInfo, TokenBucket, launch_arcjet,)from arcjet.guard.claude_managed_agents import guard_custom_tool
arcjet = launch_arcjet(key=ARCJET_KEY)lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)detect_pii = LocalDetectSensitiveInfo( deny=["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],)
async def lookup_order(event) -> dict: return { "order_id": event.input["order_id"], "status": f"shipped ({event.input['note']})", }
guarded_lookup = guard_custom_tool( guard=arcjet, run=lookup_order, action="order.looked-up", session_id=CONVERSATION_ID, rules=lambda arguments: [ lookup_limit(key="orders", requested=1), detect_pii(arguments["note"]), ],)Call guarded_lookup from your agent.custom_tool_use handler as
await guarded_lookup(event, send=..., anthropic_session_id=...). On
DENY it sends user.custom_tool_result with is_error set and
answers None.
When it answers a result, send that as user.custom_tool_result 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.
Permission policies are not a policy gate
Section titled “Permission policies are not a policy gate”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.
MCP servers you host
Section titled “MCP servers you host”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.
Fail-closed default
Section titled “Fail-closed default”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.
Correlation
Section titled “Correlation”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 agentidreturned byagents.create.ENVIRONMENT_ID: the environmentidreturned byenvironments.create.conversationId: an ID your application owns.
session = await client.beta.sessions.create( agent=AGENT_ID, environment_id=ENVIRONMENT_ID,)
# session.id addresses the session. CONVERSATION_ID correlates the# decisions, and it is yours.send = guard_events( guard=arcjet, send=client.beta.sessions.events.send, action="message.received", session_id=CONVERSATION_ID, rules=lambda arguments: [inbound(arguments["prompt"])],)
await send( session.id, events=[ { "type": "user.message", "content": [{"type": "text", "text": user_text}], } ],)Replace the following:
AGENT_ID: the agentidreturned byagents.create.ENVIRONMENT_ID: the environmentidreturned byenvironments.create.CONVERSATION_ID: an ID your application owns.
What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection onguardEvents/guard_eventsinbound beforeuser.message. - There is no
guardApprovaland nocanUseTool.always_askplususer.tool_confirmationis HITL confirmation, not policy. - There is no
PreToolUseand noquery(). 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_toolto signal a denial. Returnuser.custom_tool_resultwithis_errorset. - Don’t mint a correlation ID, and don’t reuse the Anthropic session
idas one. Pass an ID your application owns, or leave the call uncorrelated. - Don’t wrap these tools with
@arcjet/guard/claude-agent-sdk/v0orarcjet.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.
Send values to a policy
Section titled “Send values to a policy”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.
guard_custom_tool takes actor and inputs, each as a value or a callable of
the call’s arguments.
guarded_send_email = guard_custom_tool( guard=arcjet, tool=send_email_tool, action="email.sent", actor=current_user.id, inputs=lambda arguments: { "recipient": server_input.string(arguments["recipient"]), "allowed_recipients": server_input.string_list( current_user.allowed_recipients ), "body": local_input.string(arguments["body"]), },)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.
Common patterns
Section titled “Common patterns”- Prompt injection before the model sees the prompt:
guardEvents/guard_eventswith inboundrulescallingdetectPromptInjection()(prompt)/DetectPromptInjection(). - Rate limit plus PII on a custom tool:
guardCustomTool/guard_custom_toolwithtokenBucket/TokenBucketandlocalDetectSensitiveInfo()/LocalDetectSensitiveInfo()on a free-textnote. - MCP tools on a server you host: Guard inside that server’s handlers. Anthropic is the MCP client.
Related
Section titled “Related”- Framework integrations
- Claude Agent SDK agent guard
(different product: local
query()/PreToolUse) (different product: localquery()/PreToolUse) - Agent guards
- Example (JavaScript): examples/claude-managed-agent
- Example (Python): examples/fastapi-claude-managed-agents-guard
- Adapter JS:
b1378247 - Adapter Python:
c08f98a5