Integrate Arcjet Guard into a Strands Agents app
@arcjet/guard's Strands Agents v1 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({ callback })) →guardTool(). Aftertool()the object is aFunctionTool/ZodToolwhose runner path is_callback(stream()/invoke()). DENY returns a plainArcjetDenialResult. Do not throw. Do not fabricate aToolResultBlock. - MCP / unwrapped / vended tools →
guardHooks(). A Plugin whoseinitAgentregistersBeforeToolCallEventatHookOrder.SDK_FIRST - 1. On DENY it setsevent.canceltoJSON.stringify(ArcjetDenialResult). Already-branded tools are skipped. Do not useBeforeToolsEvent.cancel(that skips per-tool hooks). - Correlation →
strandsAgentContext()reads a field the integrator put oninvocationState(correlationId, thensessionId, thenrequestId). It never mints a new id. It never readstraceId. It never usesSessionManageroragent.id.
This namespace is JS @strands-agents/sdk Agent + tool({ callback }) + Plugin / addHook. Not the Python SDK. Do not also
wrap the same tool with @arcjet/guard/vercel-ai/v7,
@arcjet/guard/langgraph/v1, or @arcjet/guard/claude-managed-agents/v0.
Zod is their peer, not ours.
Screen inbound before invoke() / stream() — there is no inbound hook.
There is no first-class inbound channel, so there is no guardInbound.
Put prompt-injection (and other inbound rules) in the application
before agent.invoke() / stream(). Middleware / model hooks are not
this policy gate.
interrupt() is not a policy gate.
event.interrupt() is human-in-the-loop. Same trap as Mastra
requireApproval, Claude canUseTool, LangGraph interrupt(),
OpenAI Agents needsApproval, LangChain humanInTheLoopMiddleware,
and Google ADK requireConfirmation. There is no guardApproval /
guardInterrupt. Do not wrap interrupt() as Guard.
Deny with BeforeToolCallEvent.cancel (and guardTool on authored callbacks). BeforeToolsEvent.cancel skips per-tool hooks — do not use it.
The authored callback is the deny point for tools you own. MCP,
vended tools, and anything not wrapped with guardTool skip that
callback. guardHooks is the invoke-wide gate for those. Official:
set event.cancel to a string; tool.stream() does not run;
AfterToolCallEvent still fires.
Do not use BeforeToolsEvent.cancel. A truthy value skips
_toolExecutor.execute(), so per-tool hooks never run.
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. MCP / vended / tools you did not author getguardHooks. - 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. Put the conversation / session id you already have onagent.invoke(..., { invocationState: { sessionId } })and onguardHooks({ sessionId }). That id is the correlation id, not the user. - Is an Arcjet outage unacceptable? Every helper defaults to
onGuardError: "deny". Ask explicitly about inbound screening beforeinvoke(): failing closed there means the agent does not run for the duration of the outage, so"allow"is a routine and legitimate choice at that one call site.
The six things readers get wrong
- There is no
guardInbound. Screen prompt injection beforeagent.invoke()/stream(). Middleware / model hooks are not Guard. interrupt()is not a policy gate. It is HITL. UseguardToolorguardHooks. A denial isevent.cancel = JSON.stringify(...), not anInterruptError.- The import path is versioned and there is no alias.
@arcjet/guard/strands-agents/v1.@arcjet/guard/strands-agentsdoes not resolve. - Correlation is read, never minted. Do not call
createAgentContextinside a hook — that generates a second id and splits the Sequence. Put the id you already chose oninvocationState. Do not readtraceId. Do not useSessionManageroragent.id. - Do not use
BeforeToolsEvent.cancel. It skips the per-tool hooks thatguardHooksregisters. Deny onBeforeToolCallEvent. - A denial from
guardToolis a structured object, not a throw. Wrap both_callbackand ZodTool's_functionTool._callback. Returning a plainArcjetDenialResultis correct;FunctionToolwraps objects in aJsonBlock. Do not fabricate aToolResultBlock. Do not double-wrap with@arcjet/guard/vercel-ai/v7or@arcjet/guard/langgraph/v1.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @strands-agents/sdk (optional
peer, needed for @arcjet/guard/strands-agents/v1). Always use the
versioned path: @arcjet/guard/strands-agents/v1 resolves;
@arcjet/guard/strands-agents throws
ERR_PACKAGE_PATH_NOT_EXPORTED. Zod is Strands' peer, not ours —
install zod only if the app already uses it. Node 22+.
npm install @arcjet/guard @strands-agents/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 "@strands-agents/sdk";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/strands-agents/v1";
import { tokenBucket, localDetectSensitiveInfo, policyInput } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// Factory then text — same shape as `detectPromptInjection()(text)`.
// Scan free-text args (a note, reason, body). An opaque `orderNumber`
// will not trip EMAIL / phone / card / IP, so do not pass it here.
const detectPii = localDetectSensitiveInfo();
export 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: async ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped" }),
}),
{
action: "order.looked-up",
actor: userId,
inputs: (input) => ({
orderNumber: policyInput.server.string(input.orderNumber),
}),
rules: (input) => [
lookupLimit({ key: input.orderNumber, 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 authored callback never runs. The model receives
{ arcjetDenied: true, reason, message, retryable }as the callback return (FunctionToolwraps that object in aJsonBlock). - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable. - Prefer omitting
outputSchemaon guarded tools, or verify the schema acceptsArcjetDenialResult/ youronDenyshape. A denial is not schema-checked.
Step 3: Gate unwrapped / MCP / vended tools
import { Agent } from "@strands-agents/sdk";
import { guardHooks } from "@arcjet/guard/strands-agents/v1";
import { tokenBucket } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({
refillRate: 20,
intervalSeconds: 60,
maxTokens: 20,
});
const agent = new Agent({
tools: [lookupOrder],
plugins: [
guardHooks(arcjet, {
action: ({ toolName }) => `${toolName}.invoked`,
rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })],
sessionId: conversationId,
}),
],
});
Already-branded (guardTool) tools skip the hook gate. Tools that are
not branded — MCP, vended tools, anything not wrapped — are still
gated.
Put the same id on invoke(..., { invocationState: { sessionId } })
and on guardHooks({ sessionId }) when you need tool-time correlation
through the hook.
Step 4: Screen inbound before invoke
import { detectPromptInjection } from "@arcjet/guard";
import { strandsAgentContext } from "@arcjet/guard/strands-agents/v1";
import { arcjet } from "./arcjet.js";
const invocationState = { sessionId: conversationId };
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...strandsAgentContext({ invocationState }),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
await agent.invoke(userText, { invocationState });
There is no guardInbound.
Step 5: Correlation
Put the id you already have on the invocationState bag you pass to
invoke() / stream():
const invocationState = { sessionId: conversationId };
await agent.invoke(userText, { invocationState });
Preference order: invocationState.correlationId, then
invocationState.sessionId, then invocationState.requestId, then
documented copies on the envelope, then init.sessionId. If none is a
valid 1–256 printable-ASCII string, the call is uncorrelated rather
than joined to a generated id nobody has.
Never read traceId. Never read agent.id. Never call
SessionManager. Never call createAgentContext inside a hook.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI (before invoke), a tool deny, PII on args, a
rate limit, a hook deny on an unwrapped tool, and fail-closed
(an unreachable guard). Confirm
interrupt()is never called. - Confirm in the Arcjet dashboard that decisions share the session / request id as their correlation id.
- Manual E2E with a real
ARCJET_KEYis still-to-verify until you run it.
A full working demo will land in
arcjet/examples strands-agent
as a later 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.