Integrate Arcjet Guard into a LangGraph agent
@arcjet/guard's LangGraph v1 namespace wraps the agent's existing Arcjet
client. It never talks to the Arcjet API itself. Two surfaces, one
decision rule:
- An authored tool (
tool()/StructuredTool) →guardTool(). DENY returns a structuredArcjetDenialResult. Do not throw. - MCP / runtime-discovered / unwrapped tools →
guardToolNode(). Guards the tools aToolNodefrom@langchain/langgraph/prebuiltexecutes, in place, so execute still hits Guard. Already-branded tools are skipped (no double-call). - Correlation →
langgraphAgentContext()readsconfigurable.thread_id, then the run id, thencheckpoint_ns. It never mints a new id.
This namespace is LangGraph Graph API (StateGraph + ToolNode).
createReactAgent is deprecated in LangGraph JS v1 in favor of LangChain
createAgent / wrapToolCall. Do not build on createReactAgent. Do not
use this path for a LangChain createAgent app — that is
@arcjet/guard/langchain/v1.
Screen inbound before invoke (or at the first graph node)
There is no first-class LangGraph channel for inbound screening, so there
is no guardInbound. Put prompt-injection (and other inbound rules) in
the application before graph.invoke, or in the graph's first node.
interrupt() is not a policy gate
interrupt() / interrupt_before=["tools"] is human-in-the-loop, not
policy. Same trap as Mastra requireApproval, Claude canUseTool,
and Google ADK requireConfirmation.
There is no guardInterrupt and no guardApproval. Do not wrap them as
Guard.
ToolNode is the deny point for tools; hooks / HITL cannot enforce
Unwrapped and MCP tools run inside ToolNode. Graph hooks and HITL
pauses cannot stop tool.invoke. Use guardToolNode (or guardTool for
authored tools you invoke yourself).
guardToolNode guards the node's tools in place and returns the same
node. ToolNode's constructor captures
func: (input, config) => this.run(input, config) and run reads
this.tools, so guarding a copy would leave the original node running
unguarded tools. This also means a caller still holding the pre-wrap node
cannot bypass Guard.
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 / tools you did not author getguardToolNode. - 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.thread_idis 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 graph 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 beforegraph.invokeor in the first graph node. interrupt()is not a policy gate. It is HITL. UseguardToolorguardToolNode.- The import path is versioned and there is no alias.
@arcjet/guard/langgraph/v1.@arcjet/guard/langgraphdoes not resolve. - Correlation is read, never minted. Do not call
createAgentContextinside a LangGraph callback — that generates a second id and splits the Sequence.langgraphAgentContextreadsthread_id/checkpoint_ns/ run id and omitscorrelationIdwhen none of those is a valid id. - Do not double-wrap with
@arcjet/guard/vercel-ai/v7or@arcjet/guard/claude-managed-agents/v0. LangGraph tools are LangChaintool(), but this namespace brands them.guardToolthrows if the tool already carries the Arcjet protection brand.guardToolNodeskips already-branded tools so Guard is not double-called. - A denial from
guardToolis a structured object, not a throw.ToolNodeturns it into a realToolMessage. Because the tool did not throw, that message'sstatusissuccess— the denial is in the payload (arcjetDenied: true). Do not fabricate aToolMessageyourself to forcestatus: "error": an object that only looks like a message reaches the graph's message reducer and crashes it. IfonDenythrows, the tool still does not run and the model still receives the default denial.
Step 1: Install and find the guard client
Install @arcjet/guard (required), plus @langchain/langgraph and
@langchain/core (optional peers, needed for
@arcjet/guard/langgraph/v1). Always use the versioned path:
@arcjet/guard/langgraph/v1 resolves; @arcjet/guard/langgraph throws
ERR_PACKAGE_PATH_NOT_EXPORTED.
npm install @arcjet/guard @langchain/langgraph @langchain/core
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 "@langchain/core/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/langgraph/v1";
import { tokenBucket, localDetectSensitiveInfo, policyInput } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// Factory then text — same shape as `detectPromptInjection()(text)`.
// Scan free-text args (a note, reason, body). An opaque `orderId` will
// not trip EMAIL / phone / card / IP, so do not pass it here.
const detectPii = localDetectSensitiveInfo();
export const lookupOrder = guardTool(
arcjet,
tool(async ({ orderId, note }) => ({ orderId, note, status: "shipped" }), {
name: "lookup_order",
description: "Look up an order by ID",
schema: z.object({
orderId: z.string(),
note: z.string(),
}),
}),
{
action: "order.looked-up",
actor: userId,
inputs: (input) => ({
orderId: policyInput.server.string(input.orderId),
}),
rules: (input) => [lookupLimit({ key: input.orderId, 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 tool's
func/invokenever runs. The model receives{ arcjetDenied: true, reason, message, retryable }as the tool result content. If you invoke a guarded tool outsideToolNode, read that object and build your ownToolMessagerather than pushing it intomessages. - Default
onGuardError: "deny"blocks the tool if Arcjet is unreachable.
Step 3: Screen inbound before invoke
import { detectPromptInjection } from "@arcjet/guard";
import { langgraphAgentContext } from "@arcjet/guard/langgraph/v1";
import { arcjet } from "./arcjet.js";
const config = { configurable: { thread_id: conversationId } };
const inbound = detectPromptInjection();
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...langgraphAgentContext(config),
});
if (decision.conclusion === "DENY") {
throw new Error("message blocked");
}
await graph.invoke({ messages: [{ role: "user", content: userText }] }, config);
Or put the same screen in the graph's first node. There is no
guardInbound.
Step 4: Gate tools you did not wrap
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { guardToolNode } from "@arcjet/guard/langgraph/v1";
import { tokenBucket } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({
bucket: "mcp-access",
refillRate: 20,
intervalSeconds: 60,
maxTokens: 20,
});
export const tools = guardToolNode(arcjet, new ToolNode(mcpTools), {
action: ({ toolName }) => `${toolName}.invoked`,
rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })],
});
Pass the wrapped node to StateGraph.addNode("tools", tools). It is the
same node object you passed in, with its tools guarded in place. Use this
for tools you did not pass through guardTool. guardToolNode skips
already-branded tools, so applying both to the same authored tool does not
double-call the guard. Tools discovered after wrapping are guarded on the
next invoke.
Step 5: Correlation
Pass the checkpointer thread_id you already have on
graph.invoke(input, { configurable: { thread_id } }).
langgraphAgentContext reads it; it never calls createAgentContext.
const config = { configurable: { thread_id: conversationId } };
await graph.invoke({ messages }, config);
Preference order: configurable.thread_id, then the run id, then
configurable.checkpoint_ns. The namespace is last because it names one
subgraph ("" for the parent), so preferring it would split sibling
subgraphs of a single run across correlation ids. If none is a valid 1–256
printable-ASCII string, the call is uncorrelated rather than joined to a
generated id nobody has.
Verify the integration
npm run typecheckpasses.- Exercise inbound PI (before invoke), a tool deny, PII on args, a rate limit, an unwrapped ToolNode deny, and fail-closed (an unreachable guard).
- Confirm in the Arcjet dashboard that decisions share the thread 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 langgraph-agent
with arcjet/examples#193.
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.