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.
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.
Vercel AI SDK, Python 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 /retryAfterSeconds?). 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. Don’t fabricate aToolMessageto forcestatus: "error". That crashes the graph reducer.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. Don’t 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. The skill source is
integrate-arcjet-guard-langgraph.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardTool, guardToolNode | Guard label and capture name. A string, or for guardToolNode a function of { toolName }. |
rules | guardTool, guardToolNode | SDK rules, or a function of the parsed tool input or { toolName }. Omit to submit none. |
metadata | guardTool, guardToolNode | Nested JSON, or a function of the same input as rules. |
onGuardError | guardTool, guardToolNode | "deny" (default) or "allow". |
onDeny | guardTool | Reshape the denial object ToolNode wraps into a ToolMessage. |
Inbound screening uses direct guard(), which takes label (not action)
and fails open. See the following section.
Screen inbound text before invoke
Section titled “Screen inbound text before invoke”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. Direct guard() fails open, so an ALLOW is not proof the
rules ran. Gate on decision.hasFailedOpen() when this call site must fail
closed.
On DENY, don’t call graph.invoke. guardTool and guardToolNode already
default to fail closed. You don’t need this extra check on wrapped tools.
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" || decision.hasFailedOpen()) { throw new Error("message blocked");}
await graph.invoke({ messages: [{ role: "user", content: userText }] }, config);Human approval is not a policy gate
Section titled “Human approval is not a policy gate”interrupt() / interrupt_before=["tools"] is human-in-the-loop (HITL)
confirmation. It is not a remote policy. Same trap as Mastra requireApproval
and Claude canUseTool. There is no guardInterrupt and no guardApproval.
Don’t 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, retryAfterSeconds? }).
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. Don’t fabricate a ToolMessage
to force status: "error". That crashes the graph reducer. Scan free-text
args (a note, reason, or body). An opaque
orderNumber or tool_call_id will not trip email / phone / card / IP, so
don’t 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), ], },);Deny unwrapped tools at ToolNode
Section titled “Deny unwrapped tools at ToolNode”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 optimization: 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”guardTool and guardToolNode default to onGuardError: "deny". If Guard
cannot be evaluated, the tool does not run. Policy factories are try/caught
and follow the same default.
Direct guard() before invoke still fails open. Check
decision.hasFailedOpen() when that call site must fail closed. Skipping the
graph during an outage stops the agent answering, so some applications accept
fail-open inbound screening.
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 confirmation, not policy. - There is no
guardHooksfor LangGraph. - Don’t use deprecated
createReactAgent. - Don’t use this adapter with LangChain
createAgent/wrapToolCall. For more information about Pythoncreate_agent, see the LangChain agent guard. - Don’t call
createAgentContextinside a LangGraph callback. - Don’t also wrap these tools with
@arcjet/guard/vercel-ai/v7. - Don’t throw from
guardToolto signal a denial. Return the payload soToolNodecan wrap a realToolMessage. - Don’t fabricate a
ToolMessageto forcestatus: "error". That crashes the graph reducer. - Don’t import
@arcjet/guard/langgraph. The path is@arcjet/guard/langgraph/v1.
Common patterns
Section titled “Common patterns”- Prompt injection before invoke: Direct
guard()withdetectPromptInjection()(userText)and ahasFailedOpen()check. - Rate limit plus PII on an authored tool:
guardToolwithtokenBucketandlocalDetectSensitiveInfo()on a free-textnote. - MCP or unwrapped tools:
guardToolNodeon the sameToolNodeinstance you pass toStateGraph.addNode.