Skip to content

LangChain agent guard

LangChain agents call authored tools. Arcjet Guard sits at those boundaries so a policy can allow or deny the action before a side effect runs. For more information about the framework, see the JavaScript and Python LangChain overviews.

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.

This page covers both adapters. The JavaScript adapter is @arcjet/guard/langchain/v1, which wraps createAgent plus createMiddleware({ wrapToolCall }). The Python adapter is arcjet.guard.langchain, which wraps create_agent plus ArcjetMiddleware. Pick the tab for the language your agent runs in and don’t mix the two.

Neither adapter is the LangGraph Graph API adapter (StateGraph plus ToolNode). For that adapter, see LangGraph agent guard.

Vercel AI SDK, CrewAI, Eve, Mastra, OpenAI Agents, Genkit, and Claude Agent SDK wrappers are on Framework integrations.

Install the Guard SDK and LangChain:

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

langchain and @langchain/core are peers of @arcjet/guard, not dependencies of it. If your project already has them in the ranges that follow, install @arcjet/guard on its own so your pins don’t move.

Import helpers from the versioned path @arcjet/guard/langchain/v1. There is no unversioned alias. @arcjet/guard/langchain does not resolve. The version segment is LangChain’s major. langchain (>=1.2.0 <2) and @langchain/core (>=1 <2) are optional peers. wrapToolCall only sees runtime.configurable.thread_id as of LangChain 1.2.34, which is why langchain has the higher floor. @langchain/core keeps the >=1 <2 range langgraph/v1 already shipped. This adapter does not require @langchain/langgraph. The integration requires Node.js 22 or later.

createAgent resolves a model string such as "openai:gpt-4o" through the matching provider package, so install the one your model needs. The examples on this page use @langchain/openai. The LangChain integration requires @arcjet/guard 1.11.0 or later.

Launch one client at module scope:

import { launchArcjet } from "@arcjet/guard";
export const arcjet = launchArcjet({ key: process.env.ARCJET_KEY! });

Pick the surface that matches what you hold when the effect runs.

The integration exposes three surfaces:

  • guardTool() wraps a LangChain tool() / StructuredTool you pass to createAgent. On DENY the original func / invoke never runs. The helper returns a plain ArcjetDenialResult. It does not throw. It does not fabricate a ToolMessage. createAgent’s baseHandler wraps a non-ToolMessage in a success ToolMessage. The denial lives in the payload.
  • guardMiddleware() is the invoke-wide gate. Pass it on createAgent({ middleware }). Its wrapToolCall hook denies by returning a real ToolMessage (content is JSON of the payload, default status) without calling handler. A bare object crashes the reducer. Don’t set status: "error". Policy sits on wrapToolCall only. It skips branded (guardTool) tools when request.tool can be looked up. Tools that cannot be looked up are still gated.
  • langchainContext() reads configurable.thread_id, then caller-owned sessionId / conversationId, then init.sessionId / init.correlationId. It never mints an ID. It never reads traceId. A run that pauses on interrupt() resumes through the same config, so it keeps thread_id and later Guard decisions stay on the Sequence that started it. The interrupt and its resume value are not correlation sources. It never calls createAgentContext.

There is no guardInbound. There is no inbound hook. Screen user text with a direct guard() call before agent.invoke or agent.stream. There is no guardApproval.

Never call createAgentContext inside a LangChain callback. Don’t also wrap these tools with @arcjet/guard/langgraph/v1 or @arcjet/guard/vercel-ai/v7. Don’t wrap Python LangChain or LangGraph StateGraph / ToolNode with this adapter.

OptionHelpersDescription
actionguardTool, guardMiddlewareGuard label and capture name. Use resource.verb in the past tense. A string, or a function of the parsed tool input (or { toolName, input } on the middleware). Required on guardTool. Middleware defaults to tool.invoked.
rulesguardTool, guardMiddlewareSDK rules, or a function of the parsed tool input (or { toolName, input } on the middleware). Omit to submit none. The guard call still happens.
metadataguardTool, guardMiddlewareNested JSON, or a function of the same input as rules.
sessionIdguardTool, guardMiddlewareCaller-owned fallback when configurable.thread_id is absent. A string, or a function of the same input as rules. Prefer putting the ID on agent.invoke(..., { configurable: { thread_id } }).
onGuardErrorguardTool, guardMiddleware"deny" (default) or "allow".
onDenyguardTool, guardMiddlewareReshape the denial payload. guardTool returns that object as the tool result. guardMiddleware JSON-stringifies it onto ToolMessage.content.

Inbound screening uses direct guard(), which takes label (not action) and fails open.

On DENY the original tool never runs. The payload shape is one ArcjetDenialResult, and the envelope differs by surface and language.

  • guardTool returns the object. createAgent’s baseHandler wraps it in a success ToolMessage.
  • guardMiddleware returns a real ToolMessage whose content is JSON of that object. Default status. The denial lives in content.

It is not a throw. It is not humanInTheLoopMiddleware.

{
arcjetDenied: true,
reason: "RATE_LIMIT", // or PROMPT_INJECTION, SENSITIVE_INFO, ERROR
message:
"Arcjet denied this call (RATE_LIMIT). It may be retried after 30 seconds.",
retryable: true,
retryAfterSeconds: 30,
}

You can import ArcjetDenialResult from @arcjet/guard/langchain/v1.

Only rate-limit denials set retryable: true and include retryAfterSeconds. Other reasons tell the model not to retry.

When Guard is unavailable and onGuardError is "deny", the model receives reason: "ERROR", retryable: true, and retryAfterSeconds: 5.

Neither adapter has an inbound hook, so put prompt-injection and other inbound rules in the application before the agent runs. Act on the decision yourself. A direct guard call fails open, so an ALLOW is not proof the rules ran. On DENY, don’t run the agent.

There is no guardInbound. wrapModelCall / beforeModel / afterModel are not Guard. They intercept the model call, not user text. Policy sits on wrapToolCall only.

Direct client.guard({ label, rules, ...langchainContext(config) }) is the inbound pattern. Gate inbound on decision.hasFailedOpen() if this call site must fail closed. guardTool and guardMiddleware already default to that.

import { detectPromptInjection } from "@arcjet/guard";
import { langchainContext } from "@arcjet/guard/langchain/v1";
import { arcjet } from "./arcjet.js";
const inbound = detectPromptInjection();
const config = { configurable: { thread_id: conversationId } };
const decision = await arcjet.guard({
label: "message.received",
rules: [inbound(userText)],
...langchainContext(config),
});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) {
throw new Error("message blocked");
}

Use the authored-tool wrapper when your own code calls the tool, or when you can name the tool at wiring time. On DENY the wrapped tool does not run.

Scan free-text arguments (a note, reason, or body). An opaque order number or tool-call ID doesn’t trip email, phone, card, or IP detection, so don’t pass it to the local sensitive information helper. That helper runs on a local ML model backend.

guardTool wraps an authored tool() you pass to createAgent. That tool is the deny point for tools you own. It returns a plain ArcjetDenialResult. createAgent’s baseHandler wraps that object in a success ToolMessage.

Don’t throw. Don’t fabricate a ToolMessage. Don’t pause for a human to approve a denied call.

import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/langchain/v1";
import { tokenBucket, localDetectSensitiveInfo } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
const detectPii = localDetectSensitiveInfo({
deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],
});
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",
onGuardError: "deny",
rules: (input) => [
lookupLimit({ key: input.orderNumber, requested: 1 }),
detectPii(input.note),
],
},
);

MCP and injected tools skip an unwrapped handler. Hosted or injected tools are not a tool() deny. guardMiddleware still gates a tool call that createAgent executes through wrapToolCall.

When the model chooses the tool, pass the Arcjet middleware to the agent factory. It is the agent-wide gate for tools you did not wrap yourself.

guardMiddleware is the invoke-wide wrapToolCall gate. Pass it on createAgent({ middleware }). The hook denies by returning a real ToolMessage without calling handler. content is JSON of the payload. Default status. A bare object crashes the reducer. Policy sits on wrapToolCall only. It skips branded (guardTool) tools when request.tool can be looked up. Tools that cannot be looked up are still gated.

This is not LangGraph StateGraph / ToolNode. Don’t pass a guardToolNode result to createAgent.

import { createAgent } from "langchain";
import { guardMiddleware } from "@arcjet/guard/langchain/v1";
import { arcjet } from "./arcjet.js";
const agent = createAgent({
model: "openai:gpt-4o",
tools: [lookupOrder],
middleware: [guardMiddleware(arcjet, { sessionId: conversationId })],
});

Capture records that something happened. It never changes a decision. Put enforcement on the authored-tool wrapper or the middleware, and use capture only for visibility. For more information about recording allowed actions, see Capture events.

The JavaScript adapter has no callback handler. Record an allowed action with the core capture surface described in Capture events.

Human-in-the-loop (HITL) confirmation pauses a run so a person can approve it. It is not a policy gate. That is the same trap as Mastra requireApproval, Claude permission callbacks, LangGraph interrupt(), Genkit toolApproval, and OpenAI needsApproval.

There is no approval helper in either adapter. Don’t wrap HITL as Guard, and don’t turn a denial into an approval pause. Gate authored tools with the authored-tool wrapper, and model-selected tools with the middleware.

humanInTheLoopMiddleware / interrupt() is HITL confirmation. There is no guardApproval. Don’t deny in afterModel. Policy sits on wrapToolCall only.

The wrappers that sit around an effect fail closed. If Guard cannot be evaluated, the action does not run. Fail open only when you can accept running the action without a complete security decision, such as a read-only lookup.

A DENY conclusion always blocks, whatever the fail behavior is set to. A direct guard call still fails open. For more information about fail-open versus fail-closed behavior, see Availability and fail behavior.

guardTool and guardMiddleware default to onGuardError: "deny". If Guard cannot be evaluated, the wrapped tool does not run and wrapToolCall does not call the handler. The helpers accept onGuardError: "allow" as the only other value.

Pass one correlation ID into the run so the middleware and every guarded tool share one Sequence. Derive the ID from a session that the caller already has, and don’t mint a new one per turn. A generated ID still joins this run’s events, but it builds a Sequence that nobody searches for.

langchainContext reads configurable.thread_id first (what wrapToolCall sees on runtime.configurable as of LangChain 1.2.34), then caller-owned sessionId / conversationId, then init.sessionId / init.correlationId. It never mints an ID. It never reads traceId. It never calls createAgentContext. If nothing is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated ID.

A run that pauses on interrupt() resumes through the same config. humanInTheLoopMiddleware resumes with agent.invoke(new Command({ resume }), config). The run keeps thread_id, so later Guard decisions stay on the Sequence that started it. The interrupt and its resume value are not themselves correlation sources. Don’t derive an ID from them.

Pass the same thread_id on agent.invoke. Use sessionId on guardMiddleware only as a fallback when that ID is absent.

const config = { configurable: { thread_id: conversationId } };
await arcjet.guard({
label: "message.received",
...langchainContext(config),
});
  • There is no inbound hook and no inbound helper. Screen prompt injection before the agent runs.
  • There is no approval helper. HITL confirmation is not policy.
  • Don’t use a capture handler to deny a call. Callbacks cannot block.
  • Don’t use either adapter with the LangGraph Graph API. For more information about that adapter, see LangGraph agent guard.
  • Don’t mix the JavaScript and Python adapters on the same tool.
  • Don’t mint a new correlation ID per turn.
  • There is no guardInbound and no guardApproval.
  • Don’t treat wrapModelCall / beforeModel / afterModel as Guard. Policy sits on wrapToolCall only.
  • Don’t throw from guardTool or wrapToolCall to signal a denial.
  • Don’t fabricate a ToolMessage from guardTool. Return the plain ArcjetDenialResult.
  • Don’t return a bare object from wrapToolCall. Return a real ToolMessage with JSON content. Don’t set status: "error".
  • Don’t call createAgentContext inside a LangChain callback.
  • Don’t also wrap these tools with @arcjet/guard/langgraph/v1 or @arcjet/guard/vercel-ai/v7.
  • Don’t import @arcjet/guard/langchain. The path is @arcjet/guard/langchain/v1.
  • Tool you invoke yourself: the authored-tool wrapper with an action and rules.
  • Model-selected tools: the agent middleware with one policy per consequential tool name.
  • Rate limit per trusted identifier: key the bucket on a value you own, such as an order number. Don’t key it on free-text user input.
  • Scan a free-text note: run the local sensitive information rule on a note, reason, or body. An opaque order number is not a personal information sample, so leave it out.
  • Observe only: a capture surface, which cannot deny a call.

Python also supports guarding a plain callable with guard_action, which needs no LangChain extra.