Skip to content
Agentic Control Plane

LangChain + LangGraph + ACP — Governance Install Guide

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. Wrap tools with @governed; before each runs, ACP decides allow / deny / redact based on your workspace’s policy, the end user’s identity, rate limits, and PII detection. Same governance model as Claude Code — same /govern/tool-use endpoint, same workspace policies.

Works with any LangChain agent (ReAct, tool-calling, custom) and any LangGraph pattern (prebuilt create_react_agent, custom StateGraph, supervisor-worker).

Starter · 5-minute install. pip install acp-langchain, wrap tools with @governed, bind the end user’s JWT per request. See the governance model for the shared concepts across every framework, or the frameworks index for other options.

Install

pip install acp-langchain

Minimal governed agent (langchain.agents.create_agent)

from fastapi import FastAPI, Header
from langchain.agents import create_agent
from langchain.tools import tool
from acp_langchain import governed, set_context

app = FastAPI()

# Stack @governed under @tool — the governance check runs inside the
# tool's dispatch.
@tool
@governed("web_search")
def web_search(query: str) -> str:
    """Search the web."""
    return my_search(query)   # your code, your credentials

@tool
@governed("send_email")
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email on behalf of the user."""
    return sendmail(to, subject, body)

@app.post("/run")
def run(prompt: str, authorization: str = Header(...)):
    set_context(user_token=authorization.removeprefix("Bearer ").strip())

    agent = create_agent(
        model="openai:gpt-4o-mini",
        tools=[web_search, send_email],
    )
    result = agent.invoke({"messages": [{"role": "user", "content": prompt}]})
    return {"result": result["messages"][-1].content}

create_agent is the 2026 idiom that replaces the legacy langgraph.prebuilt.create_react_agent. The decorator pattern is identical between the two — only the agent constructor changes.

What happens on every tool call

  1. @governed POSTs { tool_name, tool_input, session_id } + Authorization: Bearer <user-jwt> to ACP’s /govern/tool-use.
  2. ACP evaluates workspace policy, user scopes, rate limits, and PII.
  3. Deny → the wrapped function returns "tool_error: <reason>". LangChain treats this as the tool’s output; the model sees it and adapts.
  4. Allow → your tool runs.
  5. Post-audit: ACP scans the output for PII. If policy says redact, the redacted output replaces the original. Audit row written, rooted in the end user’s identity.

Works with plain LangChain too

@governed is framework-agnostic — it’s just a Python decorator. Works with any LangChain agent executor:

from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_core.tools import tool
from acp_langchain import governed

@tool
@governed("my_tool")
def my_tool(arg: str) -> str: ...

agent = create_tool_calling_agent(model, tools=[my_tool], prompt=prompt)
executor = AgentExecutor(agent=agent, tools=[my_tool])

Custom LangGraph StateGraph

Same pattern — the tool decoration is what matters, not the graph shape:

from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from acp_langchain import governed

@tool
@governed("query_db")
def query_db(sql: str) -> str: ...

# Tools go into ToolNode — ToolNode invokes them, @governed runs on every invoke.
tools_node = ToolNode([query_db])

graph = StateGraph(MyState)
graph.add_node("tools", tools_node)
# ... rest of the graph as you'd normally build it

Fail-open

If /govern/tool-use times out (5s default) or is unreachable, the tool proceeds. Matches Claude Code hook behavior. Governance is never a single point of failure for the agent.

Configure your ACP workspace

Before the graph can usefully call tools, your ACP workspace needs:

  1. An IdP configured — ACP verifies the end user’s JWT against your identity provider (Firebase, Auth0, any OIDC). Dashboard → Settings → Identity Provider.
  2. Tools listed — names in @governed("...") must match tools enabled in your workspace. Dashboard → Policies → Tools.
  3. Policy — set allow/deny, rate limits, PII mode per tool. Dashboard → Policies.

What shows up in the dashboard

Every governed call appears in cloud.agenticcontrolplane.com/activity with:

  • Actor — the end user’s sub
  • Tool name — whatever you passed to @governed(...)
  • 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.

Add or replace tools

from langchain_core.tools import tool
from acp_langchain import governed

@tool
@governed("my_tool")
def my_tool(arg: str) -> str:
    """Description the LLM sees."""
    return "..."

@tool (LangChain) and @governed (ACP) stack — @governed sits closer to the function so the governance check runs inside the tool’s dispatch. Tools outside this pattern are not governed.

Limitations

  • Only tools routed through @governed are covered. Plain functions used as tools bypass governance. Intentional (explicit opt-in) but worth flagging.
  • LLM calls go direct to your provider. ACP governs tools and actions, not tokens. For per-user LLM cost attribution, pair with Portkey or LiteLLM virtual keys.
  • Async tools are supported. @governed detects coroutine functions and dispatches accordingly.
  • Pre-release. acp-langchain@0.1.x is the initial release. Pin exact versions.

Troubleshooting

Graph runs but nothing appears in the dashboard. Confirm the end user’s JWT is sent as Authorization: Bearer ... and that ACP has the IdP configured for its 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. Verify @governed is actually on the function. Inspect decorator order — @governed must sit closer to the function than @tool.