LangGraph agent guard
LangGraph graphs call authored tools and ToolNode / MCP tools. Arcjet Guard sits at those boundaries so a remote policy can allow or deny the action before a side effect runs.
Use protect() for HTTP routes. Use the LangGraph helpers on this page for
tools and inbound screening. Vercel AI SDK, LangChain, Eve, and Mastra wrappers
are on
Framework integrations.
This adapter is LangGraph Graph API (StateGraph + ToolNode). It is not
deprecated createReactAgent, and it is not LangChain createAgent /
wrapToolCall.
Install
Section titled “Install”Install the Guard SDK and LangGraph:
npm install @arcjet/guard @langchain/langgraph @langchain/coreImport helpers from the versioned path @arcjet/guard/langgraph/v1. There is
no unversioned alias. @arcjet/guard/langgraph does not resolve. The version
segment is LangGraph’s major. @langchain/langgraph (>=1 <2) and
@langchain/core (>=1 <2) are optional type-only peers. The integration
requires Node.js 22 or later.
Launch one client at module scope:
import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });Helpers
Section titled “Helpers”The integration exposes three surfaces:
guardTool()wraps a LangChaintool()/StructuredTool. It wrapsfuncandinvoke. OnDENYthe tool never runs. It returns a plainArcjetDenialResult(arcjetDenied: true, plus reason / message / retryable). It does not throw.ToolNodewraps that object into a realToolMessage. Because the tool did not throw, that message’sstatusissuccess. The denial rides in the payload (arcjetDenied: true), not the envelope.guardToolNode()guards aToolNodefrom@langchain/langgraph/prebuiltin place and returns the same node (not a copy).ToolNode’s constructor capturesfuncas an arrow bound to the instance, andrunreadsthis.tools, so a copy with a fresh tools array would leave the original executing unguarded. A frozen tools array throws at wrap time. The tools-array form returns guarded copies and leaves the input array alone. Unwrapped, MCP, and runtime-discovered tools hit Guard. Already-brandedguardTooltools are skipped (no double-call). A second wrap of an already-branded node throws.langgraphAgentContext()readsconfigurable.thread_id, then the run id, thenconfigurable.checkpoint_ns. It never mints an id. It never callscreateAgentContext.
There is no guardInbound. There is no first-class LangGraph channel, so
screen inbound with a direct guard() call before graph.invoke or in the
graph’s first node. There is no guardApproval, no guardInterrupt, and no
guardHooks for LangGraph.
Never call createAgentContext inside a LangGraph callback. Do not also wrap
these tools with @arcjet/guard/vercel-ai/v7.
The LangGraph integration skill ships with @arcjet/guard. After
npm install, copy or symlink it into your coding agent’s skills directory:
mkdir -p ~/.claude/skillscp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-langgraph ~/.claude/skills/Then ask the agent to add Arcjet Guard to this LangGraph project. In Claude
Code, run /integrate-arcjet-guard-langgraph. Until
@arcjet/guard/langgraph/v1 is published, the skill source is
integrate-arcjet-guard-langgraph.
Screen inbound before invoke (or at the first graph node)
Section titled “Screen inbound before invoke (or at the first graph node)”There is no first-class LangGraph channel, 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.
Direct client.guard({ label, rules, ...langgraphAgentContext(...) }) is the
inbound pattern.
On DENY, do not call graph.invoke.
Helpers default to onGuardError: "deny". "allow" is a legitimate choice on
the inbound guard() before invoke, because failing closed there stops the
graph running during an outage.
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);interrupt() is not a policy gate
Section titled “interrupt() is not a policy gate”interrupt() / interrupt_before=["tools"] is human-in-the-loop. It is not a
remote policy. Same trap as Mastra requireApproval and Claude canUseTool.
There is no guardInterrupt and no guardApproval. Do not wrap them as Guard.
Use guardTool for authored tools you invoke yourself.
On DENY the tool’s func / invoke never runs. guardTool returns a
plain ArcjetDenialResult
({ arcjetDenied: true, reason, message, retryable }). It does not throw.
ToolNode wraps that object into a real ToolMessage whose status is
success. The denial rides in the payload (arcjetDenied: true), not the
envelope. Scan free-text args (a note, reason, or body). An opaque
orderNumber or tool_call_id will not trip email / phone / card / IP, so
do not pass it to localDetectSensitiveInfo. That helper runs on a local ML
model backend.
Policy factories (action / rules / metadata functions) are try/caught. If
a factory throws, the helper treats it as a guard error (fail-closed by
default).
import { tool } from "@langchain/core/tools";import { z } from "zod";import { guardTool } from "@arcjet/guard/langgraph/v1";import { tokenBucket, localDetectSensitiveInfo } 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)`.const detectPii = localDetectSensitiveInfo();
export const lookupOrder = guardTool( arcjet, tool( async ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }), { name: "lookup_order", description: "Look up an order by number", schema: z.object({ orderNumber: z.string(), note: z.string(), }), }, ), { action: "order.looked-up", rules: (input) => [ lookupLimit({ key: input.orderNumber, requested: 1 }), detectPii(input.note), ], },);ToolNode is the deny point for tools; hooks / HITL cannot enforce
Section titled “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.
That is not an optimisation: ToolNode’s constructor captures
func: (input, config) => this.run(input, config), and run reads
this.tools, so a copy holding a fresh tools array would leave the original
node executing unguarded tools. A frozen tools array throws at wrap time.
Passing an array of tools instead returns guarded copies and leaves the
input array alone. Already-branded guardTool tools are skipped so Guard is
not double-called. A second wrap of an already-branded node throws. Tools
appended after wrapping — MCP discovered mid-run — are guarded on the next
invoke.
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([lookupOrder, ...mcpTools]), { action: ({ toolName }) => `${toolName}.invoked`, rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })], },);Pass the wrapped node to StateGraph.addNode("tools", tools). Use this for
tools you did not pass through guardTool. Applying both to the same authored
tool does not double-call the guard.
Fail-closed default
Section titled “Fail-closed default”LangGraph helpers default to onGuardError: "deny". If Guard cannot be
evaluated, the tool does not run. "allow" is a legitimate choice on the
inbound guard() before invoke, because failing closed there stops the
graph running during an outage. Policy factories are try/caught and follow the
same default.
Correlation
Section titled “Correlation”Pass the checkpointer thread_id you already have on
graph.invoke(input, { configurable: { thread_id } }).
langgraphAgentContext reads configurable.thread_id, then the run id,
then configurable.checkpoint_ns. It never mints an id. It never calls
createAgentContext. If none is a valid 1–256 printable-ASCII string, the
call is uncorrelated rather than joined to a generated id. correlationId
reconstructs a run only.
const config = { configurable: { thread_id: conversationId } };await graph.invoke({ messages }, config);What not to use
Section titled “What not to use”- There is no
guardInbound. Screen prompt injection beforegraph.invokeor in the first graph node. - There is no
guardInterruptand noguardApproval.interrupt()is HITL, not policy. - There is no
guardHooksfor LangGraph. - Do not use deprecated
createReactAgent. - Do not use this adapter with LangChain
createAgent/wrapToolCall. - Do not call
createAgentContextinside a LangGraph callback. - Do not also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Do not import
@arcjet/guard/langgraph. The path is@arcjet/guard/langgraph/v1.