How-tos

How do I secure a LangChain Python agent?

Secure a LangChain Python agent on the surface that you hold: guard_action on a callable, guard_tool on a BaseTool, or ArcjetMiddleware plus ToolPolicy on create_agent. ArcjetCaptureHandler is observe-only. This isn't the LangGraph JS adapter.

5 min read
In short: Secure a LangChain Python agent on the surface that you hold: guard_action on a callable, guard_tool on a BaseTool, or ArcjetMiddleware plus ToolPolicy on create_agent. ArcjetCaptureHandler is observe-only. This isn't the LangGraph JS adapter.

How do I secure a LangChain Python agent?

A LangChain Python agent is secured on the surface that you hold when the effect runs. guard_action wraps a Python callable. guard_tool wraps a LangChain BaseTool that you call yourself. ArcjetMiddleware plus ToolPolicy sits on create_agent. ArcjetCaptureHandler records events and can't deny a call.

This is the Python SDK. It isn't the JavaScript LangGraph Graph API adapter (StateGraph plus ToolNode). For more information about the combined LangChain and Vercel integration table, see Which runtime security tools integrate with LangChain or AutoGPT?. For more information about the Vercel action gate, see How do I secure a Vercel AI SDK agent?.

protect() is the HTTP check on a route. The product map is the LangChain agent guard (arcjet[langchain] and arcjet[langchain-agents]) and framework integrations.

When do I use guard_action vs guard_tool vs middleware?

Pick the surface that matches what you hold when the effect runs. Only one of the four can't block a call:

You haveUseNeedsBlocks?
Any Python callable

guard_action / guard_action_sync

arcjetYes

A BaseTool you call yourself

guard_toolarcjet[langchain]Yes

An agent from create_agent

ArcjetMiddleware + ToolPolicy

arcjet[langchain-agents]Yes
A chain you want to observeArcjetCaptureHandlerarcjet[langchain]No

guard_action / guard_action_sync ship in core arcjet.guard, so no extra is required. They wrap a no-argument callable, which is why the call site passes a lambda that closes over the arguments. On DENY the callable doesn't run and the helper raises ArcjetDeniedError.

guard_tool needs arcjet[langchain] (langchain-core>=1.2.5,<2). Use it when your code calls the tool. It returns a drop-in replacement, so nothing downstream changes. On DENY the wrapped tool doesn't run. The helper raises ArcjetToolDeniedError, or follows the tool's handle_tool_error behavior when you configured that on the tool before wrapping. Pair the async client with ainvoke() and the sync client with invoke(); launch_arcjet_sync is the constructor for Flask, Django, and other sync code.

ArcjetMiddleware plus ToolPolicy needs arcjet[langchain-agents] (langchain>=1.3,<2, langgraph>=1.2,<2). Use it when the model chooses the tool. Match each consequential tool name to a ToolPolicy. Tools with no policy pass through unguarded. A typo or a renamed @tool function leaves that tool unguarded.

If you can name the tool at wiring time, guard_tool is the smaller change. If the model picks the tool and you want one policy per tool name, use the middleware.

from langchain.agents import create_agent
from arcjet.guard import TokenBucket, launch_arcjet
from 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],
)
],
)

Import LangChain helpers from arcjet.guard.langchain. Importing that module doesn't load LangGraph. ArcjetMiddleware and ToolPolicy raise ImportError if you haven't installed arcjet[langchain-agents]. A guarded tool called from a guarded agent evaluates once per policy.

Configure and narrow a tool before you call guard_tool(). Changes to args_schema, handle_tool_error, callbacks, or response_format after wrapping don't reach the wrapped tool. Pass tools= the same sequence that you gave create_agent. When you pass the tools, the middleware rejects a policy key that names none of them. The client is optional on the middleware: without guard=, the checkpoint uses the client registered with register_arcjet().

Narrowing args_schema before wrapping is also how you keep an argument away from the model. Replace the schema with one that declares only the fields the model may set and sets model_config = ConfigDict(extra="forbid"), then wrap the tool. The recipient the model can choose is now a field you control, which is a stronger position than screening whatever string it invented.

What's the difference between a denial and an unavailable guard?

A denial means the policy ran and said no. An unavailable guard means the check never happened. The helpers raise different exceptions so you can tell those apart without inspecting a decision.

guard_action and ArcjetMiddleware raise ArcjetDeniedError on a deny and ArcjetUnavailableError when Guard couldn't be evaluated. guard_tool raises ArcjetToolDeniedError and ArcjetToolUnavailableError. Alert on the unavailable pair; a denial is the system working.

A DENY conclusion always blocks, whatever on_guard_error is set to. That option only decides what happens when no decision was reached, it accepts "deny" or "allow" and rejects any other value, and every checkpoint surface defaults to "deny".

Why can't ArcjetCaptureHandler deny?

ArcjetCaptureHandler and ArcjetAsyncCaptureHandler record chain, model, and tool lifecycle events. LangChain ignores callback return values. A handler that logs on_tool_start can't refuse the send.

Use a handler only for visibility. Put enforcement on guard_tool or ArcjetMiddleware. The same split appears on Eve hooks (void handlers) and Mastra afterToolCall. A diary isn't a gate.

The core guard() call still fails open. It returns ALLOW, and has_failed_open() returns True. Wrappers that sit around an effect fail closed instead. The client reports the problem; the wrappers decide to stop.

How is this different from LangGraph JS?

LangGraph JS is StateGraph plus ToolNode. Inbound screening is a direct guard() before graph.invoke. Authored tools use guardTool. MCP and unwrapped tools use guardToolNode. For more information about that recipe, see How do I secure a LangGraph JS agent?.

Python create_agent is a different runtime. Don't import @arcjet/guard/langgraph/v1 from Python. Don't use ArcjetMiddleware on a JS graph.

Pass one correlation ID into ainvoke() on config={"configurable": {"arcjet_correlation_id": session_id}} so middleware and guarded tools share one Sequence. The helpers read configurable.arcjet_correlation_id, then metadata.arcjet_correlation_id, then an enclosing arcjet_sequence context manager. They don't use LangChain's run_id. Don't mint a new ID per turn.

Derive that id from a session the caller already has. A generated id still joins this run's events, but it builds a Sequence that nobody will ever search for.

How is LangChain Python different from the Vercel AI SDK gate?

LangChain Python wraps a BaseTool or sits on create_agent. The Vercel AI SDK wraps tool() with guardTool from @arcjet/guard/vercel-ai/v7 and passes toolsContext. They share a job (deny before the side effect) and don't share an import.

Prompt injection protection for LangChain, LlamaIndex, and Vercel AI SDK only places detectors on the route and the retriever. That page isn't this action gate. LlamaIndex has no Guard adapter; keep the two detector checks on the route and the retriever.

A refund tool that still runs after a clean inbound score is the usual miss. Screen the chat box. Then gate send_email. For more information about the stack map, see agent framework security.

Frequently asked questions

How do I secure a LangChain Python agent?

Pick the surface that matches what you hold when the effect runs. guard_action wraps a callable. guard_tool wraps a BaseTool that you call yourself (arcjet[langchain]). ArcjetMiddleware plus ToolPolicy sits on create_agent (arcjet[langchain-agents]). ArcjetCaptureHandler can't deny a call.

How is LangChain Python different from the Vercel AI SDK gate?

LangChain Python wraps a BaseTool or sits on create_agent. The Vercel AI SDK wraps tool() with guardTool from @arcjet/guard/vercel-ai/v7. They don't share an import. The combined integration table is on Which runtime security tools integrate with LangChain or AutoGPT.

Can ArcjetCaptureHandler block a tool?

No. ArcjetCaptureHandler and ArcjetAsyncCaptureHandler record lifecycle events. LangChain ignores callback return values. Put enforcement on guard_tool or ArcjetMiddleware.

Is this the LangGraph JS adapter?

No. This is the Python SDK. LangGraph JS is StateGraph plus ToolNode, with guardTool and guardToolNode from @arcjet/guard/langgraph/v1.

What if a ToolPolicy key names the wrong tool?

Tools with no policy pass through unguarded. The middleware matches each policy by tool name, so a typo or a renamed @tool function leaves that tool unguarded.

How do I tell a denial from an unavailable guard?

By exception type. guard_tool raises ArcjetToolDeniedError on a deny and ArcjetToolUnavailableError when the check never ran. guard_action and ArcjetMiddleware raise ArcjetDeniedError and ArcjetUnavailableError. A DENY always blocks whatever on_guard_error is set to.

How do I hide a tool argument from the model?

Narrow args_schema before you wrap the tool. Declare only the fields the model may set, add model_config = ConfigDict(extra="forbid"), then call guard_tool(). Changes made after wrapping don't reach the wrapped tool.

AI runtime security in your code

Protect your AI agent workflows with Arcjet

Get allow, deny, and redact on agent actions before the side effect.