Policy & audit for LangChain & LangGraph tools
TL;DR. pip install acp-langchain, add middleware=[ACPMiddleware()] to create_agent(...), bind the end user’s JWT per request — and every tool call on that agent gets policy enforcement (allow / deny / redact), rate limits, PII detection, and an audit trail. Same control model as Claude Code. Running Deep Agents? Swap one import so subagents are covered too.
LangChain and LangGraph are the most widely deployed agent frameworks in Python. Out of the box, a production deployment shares one backend API key across every end user’s request — no per-user policy enforcement, no per-user audit trail, no way to tell downstream systems which human triggered which action.
acp-langchain closes that gap with one registration. LangChain 1.x made middleware the first-class seam around create_agent, and ACPMiddleware() sits on it: every tool the agent has — yours, prebuilt, MCP-provided, and any you add later — is policy-checked before it runs and audited after, with zero per-function decorators. Before each call, ACP decides allow / deny / redact based on your workspace’s policy, the end user’s identity, rate limits, and PII detection. Same control model as Claude Code — same /govern/tool-use endpoint, same workspace policies.
Starter · 5-minute install.
pip install acp-langchain, addmiddleware=[ACPMiddleware()]tocreate_agent, bind the JWT per request. See the runnable starter, the control model, or the frameworks index.
Install
pip install acp-langchain "langchain>=1.3.3"
ACPMiddleware needs langchain >= 1.3.3 (the create_agent middleware stack with tool-call wrap hooks). Older stacks use the decorator, below.
Minimal agent under policy (langchain.agents.create_agent)
from fastapi import FastAPI, Header
from langchain.agents import create_agent
from langchain.tools import tool
from acp_langchain import ACPMiddleware, configure, set_context
configure(base_url="https://api.agenticcontrolplane.com")
app = FastAPI()
@tool
def web_search(query: str) -> str:
"""Search the web."""
return my_search(query) # your code, your credentials
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email on behalf of the user."""
return sendmail(to, subject, body)
# One registration. Every tool on this agent is covered — the ones
# above, prebuilt tools, MCP tools, and any you add later.
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[web_search, send_email],
middleware=[ACPMiddleware()],
)
@app.post("/run")
def run(prompt: str, authorization: str = Header(...)):
set_context(user_token=authorization.removeprefix("Bearer ").strip())
result = agent.invoke({"messages": [{"role": "user", "content": prompt}]})
return {"result": result["messages"][-1].content}
No decorator on either tool. Coverage is a property of the agent, not of each function — there is nothing to forget on the tool you add next month.
create_agent is the LangChain 1.x idiom that replaces the legacy langgraph.prebuilt.create_react_agent.
Scope it if you need to:
ACPMiddleware(tools=["send_email", "delete_record"]) # only these are checked
ACPMiddleware(exclude=["get_time"]) # everything but these
What happens on every tool call
ACPMiddleware’s wrap hook (wrap_tool_call/awrap_tool_call— sync and async agents both) POSTs{ tool_name, tool_input, session_id }+Authorization: Bearer <user-jwt>to ACP’s/govern/tool-use.- ACP evaluates workspace policy, user scopes, rate limits, and PII.
- Deny → the tool function is never called. The middleware returns a synthetic
ToolMessage("tool_error: <reason>", status="error")without invoking the handler; the model sees the denial as the tool’s output and adapts. The run completes normally. - Allow → your tool runs.
- Post-audit: ACP scans the output for PII.
redact→ the redacted output replaces the original before the model sees it;block→ the model sees"[ACP] Blocked: <reason>". Audit row written, rooted in the end user’s identity.
Composes with LangChain’s own human-in-the-loop
LangChain 1.x ships HumanInTheLoopMiddleware — approve / edit / reject on proposed tool calls, with conditional when predicates since 1.3.3. That’s the pause. ACPMiddleware is the policy and the ledger: which calls need which treatment, decided from one workspace policy, recorded in one audit trail that also covers Claude Code, Cursor, and CrewAI. They stack in the same list:
from langchain.agents.middleware import HumanInTheLoopMiddleware
agent = create_agent(
model="openai:gpt-4o-mini",
tools=[web_search, send_email],
middleware=[
HumanInTheLoopMiddleware(interrupt_on={"send_email": True}), # the pause
ACPMiddleware(), # the policy + ledger
],
)
The HITL interrupt fires after the model proposes calls (after_model); ACPMiddleware runs at execution (wrap_tool_call) — an approved call still passes the policy check and lands in the audit trail.
Deep Agents
Deep Agents is LangChain’s harness on top of create_agent: planning, filesystem, subagents, skills, memory, HITL. It takes the same middleware= list, so ACPMiddleware() governs the main agent. It does not pass user middleware down to subagents, so a task delegation would run its tool calls unchecked. Swap one import:
from acp_langchain.deepagents import create_deep_agent # same signature as deepagents'
Main agent, every declarative subagent, and the auto-added general-purpose subagent are covered. Details, the verification test, and the overlap with Deep Agents’ own interrupt_on / permissions are on the Deep Agents guide.
Custom StateGraph and the legacy stack
ACPMiddleware needs the 1.x create_agent middleware stack. On the legacy chains LangChain 1.0 moved into langchain-classic — AgentExecutor, create_tool_calling_agent, the deprecated create_react_agent — or inside a custom StateGraph’s ToolNode, use the decorator. It wraps the plain function, so it works on any version:
from langchain_core.tools import tool
from acp_langchain import governed
@tool
@governed("query_db") # policy decorator INSIDE the tool decorator
def query_db(sql: str) -> str: ...
@governed goes inside @tool, closest to the function — it needs to wrap the Python function, not the Tool object LangChain builds on top of it. Coverage is per function on this path. Don’t combine it with ACPMiddleware() on the same tool, or the call is checked (and audited) twice.
Fail-open
If /govern/tool-use times out (5s default) or is unreachable, the tool proceeds with reason "fail-open". Matches Claude Code hook behavior. The policy layer is never a single point of failure for the agent.
Configure your ACP workspace
- An IdP configured — ACP verifies the end user’s JWT against your identity provider (Firebase, Auth0, any OIDC). Dashboard → Settings → Identity Provider.
- Tools listed — your tool names must match tools enabled in your workspace. Dashboard → Policies → Tools.
- Policy — set allow/deny, rate limits, PII mode per tool. Dashboard → Policies.
What shows up in the dashboard
Every checked call appears in cloud.agenticcontrolplane.com/activity with:
- Actor — the end user’s sub
- Tool name — the tool’s registered name (the
nameon the tool call) - Decision — allow / deny / redact, with reason
- Session — groups all tool calls from one request
- Findings — PII detected in input or output, if any
LangChain/LangGraph tool calls sit alongside Claude Code, Cursor, and CrewAI calls from the same user. One audit log across every agent surface.
Price and meter the model calls
ACPMiddleware is the interception plane — what your agent does. The proxy
plane covers what it spends. Both run against the same gateway, and init()
wires them together:
import acp_governance as acp
acp.init() # call before you construct any model client
Model calls now land on the same trail as tool calls, carrying real cost,
tokens, and cache economics. Constructing clients explicitly instead? Pass the
config rather than calling init():
from acp_governance import model_client_kwargs
from anthropic import Anthropic
client = Anthropic(**model_client_kwargs("anthropic"))
Shapes are "anthropic", "openai" (chat completions), and
"openai-responses". The last two are not interchangeable — /v1 serves
chat completions, /openai/v1 serves responses.
init() is all-or-nothing per provider: it sets the base URL and the key, or
it leaves that provider completely alone and tells you which one it skipped. If
OPENAI_BASE_URL already points at your own gateway, ACP won’t silently
reroute you.
Your coverage: interception ✓ (ACPMiddleware) · proxy ✓ (model calls).
Limitations
- Middleware needs langchain ≥ 1.3.3. Below that, or on
langchain-classic/ customStateGraph, coverage is per decorated function. - Deep Agents subagents need the wrapper. Plain
middleware=[ACPMiddleware()]oncreate_deep_agentcovers the main agent only — see Deep Agents. - The two planes are wired separately. The middleware covers tool calls; the proxy covers model calls — each is its own edit, see Price and meter the model calls above. Wiring tools without repointing the model client gives you control with no cost data.
- Early release.
acp-langchain@0.3.x. Pin exact versions.
Troubleshooting
Graph runs but nothing appears in the dashboard. Confirm set_context(user_token=...) runs before the agent call — without it the hooks silently no-op — and that ACP has the IdP configured for the JWT’s issuer.
401 from /govern/tool-use. The JWT is invalid, expired, or from an untrusted IdP. Check Settings → Identity Provider.
Tools run but decisions always show allow with reason fail-open. The gateway request is erroring. Check network reachability. Raise timeout via configure(timeout_s=10) if needed.
Policy says deny, but the tool still runs. Confirm ACPMiddleware() is in the middleware= list of the agent that actually executes the call (on Deep Agents, subagents need the wrapper), that the tool isn’t in exclude=, and that the name matches the policy entry. On the decorator path, @governed must be inside @tool.