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.

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 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 / retryAfterSeconds?). 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. Don’t fabricate a ToolMessage to force status: "error". That crashes the graph reducer.
  • 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. 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:

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. The skill source is integrate-arcjet-guard-langgraph.

OptionHelpersDescription
actionguardTool, guardToolNodeGuard label and capture name. A string, or for guardToolNode a function of { toolName }.
rulesguardTool, guardToolNodeSDK rules, or a function of the parsed tool input or { toolName }. Omit to submit none.
metadataguardTool, guardToolNodeNested JSON, or a function of the same input as rules.
onGuardErrorguardTool, guardToolNode"deny" (default) or "allow".
onDenyguardToolReshape 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.

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);

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),
],
},
);

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.

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.

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 confirmation, not policy.
  • There is no guardHooks for LangGraph.
  • Don’t use deprecated createReactAgent.
  • Don’t use this adapter with LangChain createAgent / wrapToolCall. For more information about Python create_agent, see the LangChain agent guard.
  • Don’t call createAgentContext inside a LangGraph callback.
  • Don’t also wrap these tools with @arcjet/guard/vercel-ai/v7.
  • Don’t throw from guardTool to signal a denial. Return the payload so ToolNode can wrap a real ToolMessage.
  • Don’t fabricate a ToolMessage to force status: "error". That crashes the graph reducer.
  • Don’t import @arcjet/guard/langgraph. The path is @arcjet/guard/langgraph/v1.
  • Prompt injection before invoke: Direct guard() with detectPromptInjection()(userText) and a hasFailedOpen() check.
  • Rate limit plus PII on an authored tool: guardTool with tokenBucket and localDetectSensitiveInfo() on a free-text note.
  • MCP or unwrapped tools: guardToolNode on the same ToolNode instance you pass to StateGraph.addNode.