Strands Agents agent guard
Strands Agents Agent workflows call authored tool handlers from the agent
loop. Arcjet Guard sits at those boundaries so a policy can allow or deny the
action before a side effect runs. For more information about the framework, see
the TypeScript
and Python Strands
Agents quick starts.
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.
This page covers both adapters. The JavaScript adapter is
@arcjet/guard/strands-agents/v1, which wraps @strands-agents/sdk tool()
plus BeforeToolCallEvent. The Python adapter is
arcjet.guard.strands_agents, which wraps @tool plus the same event. Pick
the tab for the language your agent runs in and don’t mix the two.
Vercel AI SDK, LangChain, CrewAI, Eve, Mastra, LangGraph, OpenAI Agents, Genkit, and Claude wrappers are on Framework integrations.
Install
Section titled “Install”Install the Guard SDK and Strands Agents:
npm install @arcjet/guard @strands-agents/sdk@strands-agents/sdk is a peer of @arcjet/guard, not a
dependency of it. If your project already has it in the range that
follows, install @arcjet/guard on its own so your pins don’t move.
Import helpers from the versioned path @arcjet/guard/strands-agents/v1.
There is no unversioned alias. @arcjet/guard/strands-agents does not
resolve. The version segment is the Strands Agents SDK major.
@strands-agents/sdk (>=1.1.0 <2) is an optional peer. 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[strands-agents]"The Python adapter requires arcjet 1.1.0 or later.
arcjet[strands-agents] depends on strands-agents>=1.11.0,<2.
CPython 3.10 or later.
Import helpers from arcjet.guard.strands_agents:
from arcjet.guard.strands_agents import ( guard_hooks, guard_tool, strands_agent_context,)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.
Both adapters expose an authored-tool wrapper, a hooks provider that gates unwrapped and MCP tools on the before-tool-call event, and a correlation-context helper. Neither has an inbound helper or an approval helper.
The integration exposes three surfaces:
guardTool()wraps a Strandstool({ callback })you pass tonew Agent({ tools }). OnDENYthe originalcallbacknever runs. The helper returns a plainArcjetDenialResult. It does not throw. It does not callevent.interrupt(). Prefer omittingoutputSchemaon guarded tools.guardHooks()is a Plugin whoseinitAgentregistersBeforeToolCallEventas the invoke-wide gate. Pass it onnew Agent({ plugins }). OnDENYit setsevent.cancelto a JSON string ofArcjetDenialResult. That string is the tool result error message. It does not callevent.interrupt(). It does not setBeforeToolsEvent.cancel. It skips branded (guardTool) tools. Tools that are not branded – MCP, vended, or unwrapped – are still gated.AfterToolCallEventis capture only.strandsAgentContext()reads caller-ownedinvocationState.correlationId, thensessionId, thenrequestId. It never mints an ID. It never readstraceId. It never readsagent.id. It never callscreateAgentContextorSessionManager.
There is no guardInbound. There is no inbound hook. Screen user
text with a direct guard() call before agent.invoke or
agent.stream. There is no guardApproval.
Don’t call createAgentContext inside a Strands callback. Don’t
also wrap these tools with @arcjet/guard/langchain/v1,
@arcjet/guard/langgraph/v1, or @arcjet/guard/vercel-ai/v7.
| You have | Use | Needs | Blocks a call? |
|---|---|---|---|
| Any Python callable | guard_action / guard_action_sync | arcjet | Yes |
An authored @tool | guard_tool | arcjet[strands-agents] | Yes |
| An unwrapped or MCP tool | guard_hooks (BeforeToolCallEvent) | arcjet[strands-agents] | Yes |
| A session or conversation ID you already have | strands_agent_context | arcjet[strands-agents] | No |
guard_tool wraps an authored @tool /
DecoratedFunctionTool. On DENY, or when Guard cannot be
evaluated and on_guard_error is "deny", the helper returns a
plain ArcjetDenialResult dict. The tool handler does not run.
Don’t raise. A raise is swallowed into
Error: {Type} - {message}.
guard_hooks is a hook provider. Pass it on
Agent(hooks=[...]). On DENY it sets event.cancel_tool to a
JSON string of ArcjetDenialResult. The SDK also accepts True
for the default cancel text. That cancels the tool call. Policy
sits on per-tool cancel_tool only. Don’t set
BeforeToolsEvent.cancel. That skips per-tool hooks. It does
not call event.interrupt(). Fail closed always sets
cancel_tool on error.
strands_agent_context reads a caller-owned correlationId,
then sessionId, then requestId from invocation_state or a
bare mapping. Snake-case aliases correlation_id, session_id,
and request_id are also read. Keyword fallbacks are
correlation_id=, session_id=, and request_id=. It never
mints an ID. It never reads trace_id or traceId. It never
reads agent.id.
There is no inbound helper. Screen user text with a direct
guard() call before the agent runs. There is no approval
helper. event.interrupt() is human-in-the-loop (HITL)
confirmation, not policy.
Don’t also wrap these tools with
@arcjet/guard/strands-agents/v1.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardTool, guardHooks | Guard label and capture name. Use resource.verb in the past tense. Required on guardTool, where it takes a string. On the hooks it is optional, takes a string or a function of { toolName, input }, and defaults to tool.invoked. |
rules | guardTool, guardHooks | SDK rules, or a function of the parsed tool input (or { toolName, input } on the hooks). Omit to submit none. The guard call still happens. |
metadata | guardTool, guardHooks | Nested JSON, or a function of the same input as rules. |
sessionId | guardTool, guardHooks | Caller-owned fallback when invocationState has no correlationId, sessionId, or requestId. A string, or a function of the same input as rules. Prefer putting the ID on agent.invoke(prompt, { invocationState }). |
onGuardError | guardTool, guardHooks | "deny" (default) or "allow". |
onDeny | guardTool, guardHooks | Reshape the denial payload. guardTool returns that object as the tool result. guardHooks JSON-stringifies it onto event.cancel. |
Inbound screening uses direct guard(), which takes label (not
action) and fails open.
guard_tool accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | Yes | Client from launch_arcjet or launch_arcjet_sync. |
tool | Yes | The authored @tool handler to gate. |
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
actor | No | Who is acting, or a function of the parsed tool arguments. Take it from authenticated application state, never from a model-produced argument. |
inputs | No | Typed remote-policy inputs, or a function of the parsed tool arguments. |
rules | No | Bound SDK rule inputs, or a function of the parsed tool arguments. Omit to submit none. |
metadata | No | Capture metadata, or a function of the parsed tool arguments. |
correlation_id | No | Caller-owned fallback when the object you pass to strands_agent_context has no ID. Never minted. |
session_id | No | Same, when the application calls the ID a session. Ignored when correlation_id is set. |
request_id | No | Same, when the application calls the ID a request. Ignored when correlation_id or session_id is set. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
guard_hooks accepts this contract:
| Option | Required | Description |
|---|---|---|
guard | No | Client from launch_arcjet or launch_arcjet_sync. Omit to use the client you registered with register_arcjet. |
action | No | Guard label and capture name. A string, or a function of the tool-call envelope. Defaults to {tool_name}.invoked. |
actor | No | Who is acting, or a function of the tool-call envelope. Take it from authenticated application state, never from a model-produced argument. |
inputs | No | Typed remote-policy inputs, or a function of the tool-call envelope. |
rules | No | Bound SDK rule inputs, or a function of tool_use.input plus tool_name. Omit to submit none. |
metadata | No | Capture metadata, or a function of the tool-call envelope. |
correlation_id | No | Caller-owned fallback when invocation_state has no ID. Never minted. |
session_id | No | Same, when the application calls the ID a session. Ignored when correlation_id is set. |
request_id | No | Same, when the application calls the ID a request. Ignored when correlation_id or session_id is set. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
Inbound screening uses direct guard(), which takes label (not
action) and fails open.
Denial payload
Section titled “Denial payload”On DENY the original tool never runs. The payload shape is one
ArcjetDenialResult. The envelope differs by surface:
{ "arcjetDenied": true, "reason": "RATE_LIMIT", "message": "Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.", "retryable": true, "retryAfterSeconds": 30}The reason is one of RATE_LIMIT, PROMPT_INJECTION, SENSITIVE_INFO, or
ERROR. Only rate-limit denials set retryable to true and include a retry
delay. Other reasons tell the model not to retry. It is not a throw or a raise,
and it is not an interrupt.
guardToolreturns the object as the tool result.guardHookssetsevent.cancelto a JSON string of that object.BeforeToolCallEvent.cancelis the policy gate. A string value is the tool result error message.
You can import ArcjetDenialResult from
@arcjet/guard/strands-agents/v1.
When Guard is unavailable and onGuardError is "deny", the
model receives reason: "ERROR", retryable: true, and
retryAfterSeconds: 5.
guard_toolreturns a plainArcjetDenialResultdict as the tool result.guard_hookssetsevent.cancel_toolto a JSON string of that dict. The SDK also acceptsTruefor the default cancel text.
Don’t raise from the wrapper. A raise is swallowed into
Error: {Type} - {message}.
Screen user text before the agent runs
Section titled “Screen user text before the agent runs”There is no inbound hook and no inbound helper. Put prompt-injection and other inbound rules in the application before you call the agent.
A direct guard call fails open, so an ALLOW is not proof the rules ran. Gate
inbound on the failed-open check if this call site must fail closed. The
authored-tool wrapper and the hooks provider already default to that. On
DENY, don’t call the agent.
import { launchArcjet, detectPromptInjection, localDetectSensitiveInfo, tokenBucket,} from "@arcjet/guard";import { guardTool, guardHooks, strandsAgentContext,} from "@arcjet/guard/strands-agents/v1";import { Agent, tool } from "@strands-agents/sdk";import { z } from "zod";
const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});const inbound = detectPromptInjection();
const lookupOrder = guardTool( arcjet, tool({ name: "lookup_order", description: "Look up an order by number", inputSchema: z.object({ orderNumber: z.string(), note: z.string(), }), callback: ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [ lookupLimit({ key: input.orderNumber, requested: 1 }), detectPii(input.note), ], },);
export async function runAgent(conversationId: string, userText: string) { const invocationState = { sessionId: conversationId };
const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...strandsAgentContext({ invocationState }), });
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("message blocked"); }
const agent = new Agent({ tools: [lookupOrder], plugins: [guardHooks(arcjet, { sessionId: conversationId })], });
return agent.invoke(userText, { invocationState });}from strands import Agentfrom arcjet.guard import DetectPromptInjection, launch_arcjetfrom arcjet.guard.strands_agents import ( guard_hooks, strands_agent_context,)
arcjet = launch_arcjet(key=ARCJET_KEY)inbound = DetectPromptInjection()
async def run_agent(conversation_id: str, user_text: str): app_context = {"session_id": conversation_id} derived = strands_agent_context(app_context)
decision = await arcjet.guard( label="message.received", rules=[inbound(user_text)], correlation_id=derived.correlation_id, ) if decision.conclusion == "DENY" or decision.has_failed_open(): raise RuntimeError("message blocked")
agent = Agent( tools=[lookup_order], hooks=[guard_hooks(guard=arcjet, session_id=conversation_id)], ) return agent(user_text)Gate authored tool handlers
Section titled “Gate authored tool handlers”The authored-tool wrapper is the deny point for tools you own. On DENY the
inner handler never runs a side effect.
Scan free-text arguments (a note, reason, or body). An opaque order number or tool use ID doesn’t trip email, phone, card, or IP detection, so don’t pass it to the local sensitive information helper. That helper runs on a local ML model backend.
Pass wrapped tools on the agent’s tools list.
guardTool wraps an authored tool() you pass to
new Agent({ tools }). It returns a plain ArcjetDenialResult. Don’t throw
and don’t call event.interrupt().
import { tool } from "@strands-agents/sdk";import { z } from "zod";import { guardTool } from "@arcjet/guard/strands-agents/v1";import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});
const lookupOrder = guardTool( arcjet, tool({ name: "lookup_order", description: "Look up an order by number", inputSchema: z.object({ orderNumber: z.string(), note: z.string(), }), callback: ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }), }), { action: "order.looked-up", onGuardError: "deny", rules: (input) => [ lookupLimit({ key: input.orderNumber, requested: 1 }), detectPii(input.note), ], },);guard_tool wraps an authored @tool /
DecoratedFunctionTool. It returns a plain ArcjetDenialResult dict. Don’t
raise.
from strands import toolfrom arcjet.guard import ( LocalDetectSensitiveInfo, TokenBucket, launch_arcjet,)from arcjet.guard.strands_agents import guard_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"],)
@tooldef lookup_order(order_id: str, note: str) -> dict: """Look up an order by ID.""" return {"order_id": order_id, "note": note, "status": "shipped"}
guarded_lookup = guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", rules=lambda arguments: [ lookup_limit(key="orders", requested=1), detect_pii(arguments["note"]), ],)Deny unwrapped tools with the hooks provider
Section titled “Deny unwrapped tools with the hooks provider”BeforeToolCallEvent is the per-tool gate for anything the authored-tool
wrapper doesn’t cover. The callback denies by cancelling the tool call, so the
tool does not run.
Policy sits on the per-tool cancel field only. Don’t set
BeforeToolsEvent.cancel, which skips per-tool hooks. The provider skips
branded tools that already wrap themselves. Tools that are not branded – MCP,
vended, or unwrapped – are still gated. The after-tool-call event is capture
only, and an interrupt is not a deny.
MCP and vended tools skip an unwrapped handler, so they are not an authored-tool deny. The hooks provider still gates a tool call that the agent executes through the before-tool-call event.
guardHooks is a Plugin, so pass it on new Agent({ plugins }) rather than
agent.addHook. initAgent registers BeforeToolCallEvent as the
invoke-wide gate and denies by setting event.cancel to a JSON string of the
denial payload.
This is not LangChain createAgent / wrapToolCall. Don’t pass
guardMiddleware to a Strands Agent.
import { Agent } from "@strands-agents/sdk";import { guardHooks } from "@arcjet/guard/strands-agents/v1";import { arcjet } from "./arcjet.js";
const agent = new Agent({ tools: [lookupOrder], plugins: [guardHooks(arcjet, { sessionId: conversationId })],});guard_hooks is a hook provider, so pass it on Agent(hooks=[...]). The
callback denies by setting event.cancel_tool to a JSON string of the denial
payload. The SDK also accepts True for the default cancel text.
This is not the JavaScript @arcjet/guard/strands-agents/v1
adapter. Don’t pass guardHooks from that package to a Python
Agent.
from strands import Agentfrom arcjet.guard.strands_agents import guard_hooksfrom arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)
agent = Agent( tools=[guarded_lookup], hooks=[guard_hooks(guard=arcjet, session_id=conversation_id)],)Human approval is not a policy gate
Section titled “Human approval is not a policy gate”An interrupt pauses the run for human-in-the-loop (HITL) confirmation. It asks a person rather than evaluating a policy, so it is not a Guard enforcement point. For more information, see Human approval is not a policy gate.
There is no approval helper in either adapter. Don’t wrap an interrupt as Guard, and don’t turn a denial into an approval pause. Gate authored tools with the authored-tool wrapper, and unwrapped ones through the hooks provider.
Fail-closed default
Section titled “Fail-closed default”The authored-tool wrapper and the hooks provider fail closed. If Guard cannot be evaluated, the wrapped tool does not run and the before-tool-call event cancels the call instead of running the tool.
Fail open only when executing without a complete security decision is
acceptable, such as a read-only lookup. A DENY conclusion always blocks,
whatever the fail behavior is set to. A direct guard call still fails open.
For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.
guardTool and guardHooks default to onGuardError: "deny". The only other
accepted value is "allow".
guard_tool and guard_hooks default to
on_guard_error="deny". The helpers reject any value other than "allow".
has_failed_open() returns True when the core guard() call failed open.
Correlation
Section titled “Correlation”The agent has no conversation or session ID unless you put one on the state you pass into the run. Pass the ID that you already have and don’t mint one.
Preference order is a caller-owned correlation ID, then a session ID, then a request ID. The context helper never mints an ID, never reads a trace ID, and never reads the agent ID. If the caller passed none of those keys, the call is uncorrelated rather than joined to a generated ID.
A run that pauses on an interrupt resumes through a later invocation. Put the same state on that resume call so later Guard decisions stay on the Sequence that started it. The interrupt and its resume value are not correlation sources.
Derive the ID from a session that the caller already has. A generated ID still joins this run’s events, but it builds a Sequence that nobody searches for.
strandsAgentContext reads caller-owned
invocationState.correlationId first, then sessionId, then
requestId. It never calls createAgentContext or SessionManager.
Pass the same invocationState on agent.invoke. Use sessionId
on guardHooks only as a fallback when that object has no ID.
const invocationState = { sessionId: conversationId };
await arcjet.guard({ label: "message.received", ...strandsAgentContext({ invocationState }),});
await agent.invoke(userText, { invocationState });strands_agent_context reads a caller-owned correlationId
first, then sessionId, then requestId from invocation_state
or a bare mapping. Snake-case aliases correlation_id,
session_id, and request_id are also read.
app_context = {"session_id": conversation_id}derived = strands_agent_context(app_context)
agent(user_text)What not to use
Section titled “What not to use”- There is no inbound helper. Screen prompt injection before you call the agent.
- There is no approval helper. An interrupt is HITL confirmation, not policy.
- Don’t signal a denial by throwing or raising. Denial is the cancel field on the hooks, or the denial payload from the authored-tool wrapper.
- Don’t set
BeforeToolsEvent.cancel. That skips per-tool hooks. - Don’t mint a correlation ID, read a trace ID, or read the agent ID.
- There is no
guardInboundand noguardApproval. - Don’t treat
event.interrupt()as Guard. Policy sits onBeforeToolCallEvent.cancelonly. - Don’t pass
guardHooks()toagent.addHook. It is a Plugin. Pass it onnew Agent({ plugins }). - Don’t call
createAgentContextinside a Strands callback. - Don’t also wrap these tools with
@arcjet/guard/langchain/v1,@arcjet/guard/langgraph/v1, or@arcjet/guard/vercel-ai/v7. - Don’t import
@arcjet/guard/strands-agents. The path is@arcjet/guard/strands-agents/v1.
- Don’t raise from
guard_toolor theBeforeToolCallEventcallback to signal a denial. Denial iscancel_tool(Trueor a string) on the hooks, or theArcjetDenialResultdict fromguard_tool. A raise is swallowed intoError: {Type} - {message}. - Don’t treat
event.interrupt()as Guard. Policy sits onBeforeToolCallEvent.cancel_toolonly. - Don’t wrap these tools with
@arcjet/guard/strands-agents/v1.
Common patterns
Section titled “Common patterns”- Prompt injection before the run: a direct guard call with the prompt injection rule and a failed-open check.
- Rate limit per trusted identifier: key the bucket on a value you own, such as an order number. Don’t key it on free-text user input.
- Scan a free-text note: run the local sensitive information rule on a note, reason, or body. An opaque order number is not a personal information sample, so leave it out.
- Correlate the run: put the session ID you already have on the invocation state. Don’t mint an ID.
Related
Section titled “Related”- Framework integrations
- LangChain agent guard
- TanStack AI agent guard
- LangGraph agent guard
- Genkit agent guard
- Mastra agent guard
- Vercel AI SDK agent guard
- OpenAI Agents agent guard
- CrewAI agent guard
- Vercel Eve agent guard
- Claude Agent SDK agent guard
- Python Guard SDK reference
- Agent guards
- JavaScript example: examples/strands-agent
- Python example: examples/fastapi-strands-agents-guard
- Python adapter:
ed8b5766