Skip to content

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 the Guard SDK and Strands Agents:

Terminal window
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! });

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 Strands tool({ callback }) you pass to new Agent({ tools }). On DENY the original callback never runs. The helper returns a plain ArcjetDenialResult. It does not throw. It does not call event.interrupt(). Prefer omitting outputSchema on guarded tools.
  • guardHooks() is a Plugin whose initAgent registers BeforeToolCallEvent as the invoke-wide gate. Pass it on new Agent({ plugins }). On DENY it sets event.cancel to a JSON string of ArcjetDenialResult. That string is the tool result error message. It does not call event.interrupt(). It does not set BeforeToolsEvent.cancel. It skips branded (guardTool) tools. Tools that are not branded – MCP, vended, or unwrapped – are still gated. AfterToolCallEvent is capture only.
  • strandsAgentContext() reads caller-owned invocationState.correlationId, then sessionId, then requestId. It never mints an ID. It never reads traceId. It never reads agent.id. It never calls createAgentContext or SessionManager.

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.

OptionHelpersDescription
actionguardTool, guardHooksGuard 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.
rulesguardTool, guardHooksSDK rules, or a function of the parsed tool input (or { toolName, input } on the hooks). Omit to submit none. The guard call still happens.
metadataguardTool, guardHooksNested JSON, or a function of the same input as rules.
sessionIdguardTool, guardHooksCaller-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 }).
onGuardErrorguardTool, guardHooks"deny" (default) or "allow".
onDenyguardTool, guardHooksReshape 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.

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.

  • guardTool returns the object as the tool result.
  • guardHooks sets event.cancel to a JSON string of that object. BeforeToolCallEvent.cancel is 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.

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

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

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

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.

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".

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 });
  • 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 guardInbound and no guardApproval.
  • Don’t treat event.interrupt() as Guard. Policy sits on BeforeToolCallEvent.cancel only.
  • Don’t pass guardHooks() to agent.addHook. It is a Plugin. Pass it on new Agent({ plugins }).
  • 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.
  • Don’t import @arcjet/guard/strands-agents. The path is @arcjet/guard/strands-agents/v1.
  • 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.