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
Section titled “Install”Install the Guard SDK and LangChain:
npm install @arcjet/guard langchain @langchain/corelangchain 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! });guard_action and guard_action_sync ship in the core arcjet.guard
package, so they need no extra.
Install an extra only for the LangChain surfaces you use:
pip install "arcjet[langchain]"arcjet[langchain] provides guard_tool, ArcjetCaptureHandler, and
ArcjetAsyncCaptureHandler. It depends on langchain-core>=1.2.5,<2.
pip install "arcjet[langchain-agents]"arcjet[langchain-agents] adds ArcjetMiddleware and ToolPolicy. It
depends on langchain>=1.3,<2 and langgraph>=1.2,<2.
Import LangChain helpers from one path:
from arcjet.guard.langchain import ( ArcjetCaptureHandler, ArcjetMiddleware, ToolPolicy, guard_tool,)Importing arcjet.guard.langchain does not load LangGraph. ArcjetMiddleware
and ToolPolicy raise ImportError if you haven’t installed
arcjet[langchain-agents].
Launch one client at module scope:
from arcjet.guard import launch_arcjet
arcjet = launch_arcjet(key=ARCJET_KEY)Use launch_arcjet_sync with Flask, Django, or other sync code.
Helpers
Section titled “Helpers”Pick the surface that matches what you hold when the effect runs.
The integration exposes three surfaces:
guardTool()wraps a LangChaintool()/StructuredToolyou pass tocreateAgent. OnDENYthe originalfunc/invokenever runs. The helper returns a plainArcjetDenialResult. It does not throw. It does not fabricate aToolMessage.createAgent’sbaseHandlerwraps a non-ToolMessagein a successToolMessage. The denial lives in the payload.guardMiddleware()is the invoke-wide gate. Pass it oncreateAgent({ middleware }). ItswrapToolCallhook denies by returning a realToolMessage(contentis JSON of the payload, default status) without callinghandler. A bare object crashes the reducer. Don’t setstatus: "error". Policy sits onwrapToolCallonly. It skips branded (guardTool) tools whenrequest.toolcan be looked up. Tools that cannot be looked up are still gated.langchainContext()readsconfigurable.thread_id, then caller-ownedsessionId/conversationId, theninit.sessionId/init.correlationId. It never mints an ID. It never readstraceId. A run that pauses oninterrupt()resumes through the same config, so it keepsthread_idand later Guard decisions stay on the Sequence that started it. The interrupt and its resume value are not correlation sources. It never callscreateAgentContext.
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.
| You have | Use | Needs | Blocks a call? |
|---|---|---|---|
| Any Python callable | guard_action / guard_action_sync | arcjet | Yes |
A LangChain BaseTool you call yourself | guard_tool | arcjet[langchain] | Yes |
An agent from create_agent | ArcjetMiddleware + ToolPolicy | arcjet[langchain-agents] | Yes |
| A chain or agent you want to observe | ArcjetCaptureHandler / ArcjetAsyncCaptureHandler | arcjet[langchain] | No |
If you can name the tool at wiring time, guard_tool is the smaller change.
It returns a drop-in replacement for the tool, so nothing downstream
changes. If the model picks the tool and you want one policy per tool name,
use the middleware. They compose: a guarded tool called from a guarded agent
evaluates once per policy.
Helper options
Section titled “Helper options”| Option | Helpers | Description |
|---|---|---|
action | guardTool, guardMiddleware | Guard 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. |
rules | guardTool, guardMiddleware | SDK rules, or a function of the parsed tool input (or { toolName, input } on the middleware). Omit to submit none. The guard call still happens. |
metadata | guardTool, guardMiddleware | Nested JSON, or a function of the same input as rules. |
sessionId | guardTool, guardMiddleware | Caller-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 } }). |
onGuardError | guardTool, guardMiddleware | "deny" (default) or "allow". |
onDeny | guardTool, guardMiddleware | Reshape 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.
guard_tool, guard_action, and ToolPolicy share this contract:
| Option | Required | Description |
|---|---|---|
action | Yes | Guard label and capture name. Use resource.verb in the past tense, such as email.sent. |
rules | No | Bound SDK rule inputs. Empty is normal and still contacts Guard so a remote policy can apply. |
actor | No | Trusted identity. On guard_tool, a callable receives RunnableConfig. On ToolPolicy, a callable receives parsed arguments. |
inputs | No | Named values built with server_input.* or local_input.*. On guard_tool, a callable receives (arguments, config). On ToolPolicy, a callable receives parsed arguments. |
on_guard_error | No | "deny" (default) or "allow". A real DENY always blocks. |
metadata | No | Nested JSON on ToolPolicy. |
ArcjetMiddleware also accepts guard (optional if you called
register_arcjet()), policies (tool name to ToolPolicy), and tools (the
same sequence you passed to create_agent, so a typo is refused at init).
Denial payload and errors
Section titled “Denial payload and errors”On DENY the original tool never runs. The payload shape is one
ArcjetDenialResult, and the envelope differs by surface and language.
guardToolreturns the object.createAgent’sbaseHandlerwraps it in a successToolMessage.guardMiddlewarereturns a realToolMessagewhosecontentis JSON of that object. Default status. The denial lives incontent.
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.
Python raises instead of returning a payload, so application code can catch the failure:
guard_actionandguard_action_syncraiseArcjetDeniedErroron a denial andArcjetUnavailableErrorwhen Guard cannot be evaluated.guard_toolraisesArcjetToolDeniedErroron a denial andArcjetToolUnavailableErrorwhen Guard cannot be evaluated. It follows the tool’shandle_tool_errorbehavior when you configured that on the tool before wrapping.ArcjetMiddlewareraisesArcjetDeniedErroron a denial andArcjetUnavailableErrorwhen Guard cannot be evaluated.
The two error families are distinct on purpose. A denial means policy evaluated and said no. Unavailability means the check never happened.
Screen user text before the agent runs
Section titled “Screen user text before the agent runs”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");}Put the inbound rule on the guard_action call that wraps the work, so the
prompt is screened and the effect is gated in one checkpoint. guard_action
wraps a no-argument async callable and guard_action_sync wraps a
no-argument sync callable. Neither extra is required.
from arcjet.guard import ( DetectPromptInjection, TokenBucket, guard_action, launch_arcjet,)
arcjet = launch_arcjet(key=ARCJET_KEY)job_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="jobs",)inbound = DetectPromptInjection()
async def process_job(user_id: str, message: str) -> str: return await guard_action( lambda: run_job(message), guard=arcjet, action="job.processed", rules=[ job_limit(key=user_id, requested=1), inbound(message), ], )Wrap a tool you call yourself
Section titled “Wrap a tool you call yourself”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.
guard_tool wraps a synchronous or asynchronous BaseTool. Use the async
Guard client with ainvoke() and the sync client with invoke().
Configure and narrow the tool before you call guard_tool(). Changes to
args_schema, handle_tool_error, callbacks, or response_format after
wrapping do not reach the wrapped tool.
from arcjet.guard import TokenBucket, launch_arcjetfrom arcjet.guard.langchain import guard_toolfrom langchain_core.tools import tool
arcjet = launch_arcjet(key=ARCJET_KEY)lookup_limit = TokenBucket( refill_rate=10, interval_seconds=60, max_tokens=10, bucket="lookups",)
@toolasync def lookup_order(order_id: str, note: str) -> dict: """Look up an order by ID.""" return {"order_id": order_id, "note": note, "status": "shipped"}
guarded_lookup_order = guard_tool( guard=arcjet, tool=lookup_order, action="order.looked-up", rules=[lookup_limit(key="orders", requested=1)],)To hide an argument from the model, narrow args_schema on the tool first,
then wrap it:
from pydantic import BaseModel, ConfigDict
class PublicEmailArgs(BaseModel): model_config = ConfigDict(extra="forbid") to: str
send_email.args_schema = PublicEmailArgsguarded_send_email = guard_tool( guard=arcjet, tool=send_email, action="email.sent",)Guard an agent’s tool calls
Section titled “Guard an agent’s tool calls”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 })],});Pass ArcjetMiddleware to create_agent and match each consequential tool
name to a ToolPolicy. Tools with no policy pass through unguarded.
Pass tools= the same sequence you gave create_agent. The middleware
matches each policy by tool name, so a typo or a renamed @tool function
leaves that tool unguarded. When you pass the tools, the middleware rejects a
policy key that names none of them.
The client is optional. Without guard=, the checkpoint uses the client
registered with register_arcjet().
from langchain.agents import create_agent
from arcjet.guard import TokenBucket, launch_arcjetfrom arcjet.guard.langchain import ArcjetMiddleware, ToolPolicy
arcjet = launch_arcjet(key=ARCJET_KEY)
send_limit = TokenBucket( refill_rate=5, interval_seconds=60, max_tokens=5, bucket="email",)
agent = create_agent( model="openai:gpt-4o", tools=[send_email, search_orders], middleware=[ ArcjetMiddleware( guard=arcjet, policies={ "send_email": ToolPolicy( action="email.sent", rules=[send_limit(key="email", requested=1)], ) }, tools=[send_email, search_orders], ) ],)Observe a run without blocking
Section titled “Observe a run without blocking”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.
ArcjetCaptureHandler and ArcjetAsyncCaptureHandler record chain, model,
and tool lifecycle events. They cannot deny a call. LangChain ignores
callback return values.
from arcjet.guard.langchain import ArcjetCaptureHandler
await agent.ainvoke( {"messages": [{"role": "user", "content": prompt}]}, config={"callbacks": [ArcjetCaptureHandler()]},)Human approval is not a policy gate
Section titled “Human approval is not a policy gate”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.
Use guard_tool on tools you call yourself, or ArcjetMiddleware with a
ToolPolicy per consequential tool name. ArcjetCaptureHandler cannot deny a
call, so don’t reach for it as a gate.
Fail-closed default
Section titled “Fail-closed default”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.
These surfaces default to on_guard_error="deny":
guard_action/guard_action_syncguard_toolArcjetMiddleware
Set on_guard_error="allow" only when you can accept running the action
without a complete security decision. The helpers reject any other value.
The core guard() call still fails open. It returns ALLOW, and
has_failed_open() returns True.
Correlation
Section titled “Correlation”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),});Pass one correlation ID into ainvoke():
await agent.ainvoke( {"messages": [{"role": "user", "content": prompt}]}, config={"configurable": {"arcjet_correlation_id": session_id}},)The helpers read configurable.arcjet_correlation_id, then
metadata.arcjet_correlation_id, then an enclosing arcjet_sequence. They
do not use LangChain’s run_id.
from arcjet.guard import arcjet_sequence
with arcjet_sequence(correlation_id=session_id): await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]})What not to use
Section titled “What not to use”- 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
guardInboundand noguardApproval. - Don’t treat
wrapModelCall/beforeModel/afterModelas Guard. Policy sits onwrapToolCallonly. - Don’t throw from
guardToolorwrapToolCallto signal a denial. - Don’t fabricate a
ToolMessagefromguardTool. Return the plainArcjetDenialResult. - Don’t return a bare object from
wrapToolCall. Return a realToolMessagewith JSONcontent. Don’t setstatus: "error". - Don’t call
createAgentContextinside a LangChain callback. - Don’t also wrap these tools with
@arcjet/guard/langgraph/v1or@arcjet/guard/vercel-ai/v7. - Don’t import
@arcjet/guard/langchain. The path is@arcjet/guard/langchain/v1.
- Don’t change
args_schema,handle_tool_error,callbacks, orresponse_formaton a tool afterguard_tool(). - Don’t import
ArcjetMiddlewareorToolPolicyunless you installedarcjet[langchain-agents]. - Don’t mint a new
arcjet_correlation_idper turn.
Common patterns
Section titled “Common patterns”- 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.
Related
Section titled “Related”- Framework integrations
- LangGraph agent guard
- CrewAI agent guard
- TanStack AI agent guard
- Vercel AI SDK agent guard
- Genkit agent guard
- OpenAI Agents agent guard
- Vercel Eve agent guard
- Mastra agent guard
- Claude Agent SDK agent guard
- Python Guard SDK reference
- Agent guards
- Python example: examples/fastapi-langchain-guard
- JavaScript adapter:
c49abcc1