Skip to content

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

Terminal window
npm install @arcjet/guard @langchain/langgraph @langchain/core

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

The integration exposes three surfaces:

  • guardTool() wraps a LangChain tool() / StructuredTool. It wraps func and invoke. On DENY the tool never runs. It returns a plain ArcjetDenialResult (arcjetDenied: true, plus reason / message / retryable). It does not throw. ToolNode wraps that object into a real ToolMessage. Because the tool did not throw, that message’s status is success. The denial rides in the payload (arcjetDenied: true), not the envelope.
  • guardToolNode() guards a ToolNode from @langchain/langgraph/prebuilt in place and returns the same node (not a copy). ToolNode’s constructor captures func as an arrow bound to the instance, and run reads this.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-branded guardTool tools are skipped (no double-call). A second wrap of an already-branded node throws.
  • langgraphAgentContext() reads configurable.thread_id, then the run id, then configurable.checkpoint_ns. It never mints an id. It never calls createAgentContext.

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:

Terminal window
mkdir -p ~/.claude/skills
cp -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() / 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.

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.

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);
  • There is no guardInbound. Screen prompt injection before graph.invoke or in the first graph node.
  • There is no guardInterrupt and no guardApproval. interrupt() is HITL, not policy.
  • There is no guardHooks for LangGraph.
  • Do not use deprecated createReactAgent.
  • Do not use this adapter with LangChain createAgent / wrapToolCall.
  • Do not call createAgentContext inside 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.