Deep Agents + ACP — Policy & Audit Install Guide
Deep Agents is LangChain’s open-source harness for Claude Code-style agents: planning, a filesystem, subagent delegation, context management, memory, skills, and human-in-the-loop, on top of LangChain 1.x create_agent. MIT licensed, any model.
It takes the same middleware= list as create_agent, so ACPMiddleware() from acp-langchain already governs the main agent’s tools: the filesystem tools, skill reads, the task delegation call, and your own. What it does not reach is the subagents. acp-langchain 0.3 closes that with one import swap. Same control model as Claude Code — same /govern/tool-use endpoint, same workspace policies.
Starter · 5-minute install.
pip install "acp-langchain[deepagents]", importcreate_deep_agentfromacp_langchain.deepagents, bind the JWT per request. See the runnable starter, the control model, or the frameworks index.
Install
pip install "acp-langchain[deepagents]"
Needs deepagents >= 0.7 and langchain >= 1.3.3.
Minimal agent under policy, subagents included
from fastapi import FastAPI, Header
from langchain.tools import tool
from acp_langchain import configure, set_context
from acp_langchain.deepagents import create_deep_agent # not from deepagents
configure(base_url="https://api.agenticcontrolplane.com")
app = FastAPI()
@tool
def lookup_record(id: str) -> str:
"""Look up a record by ID."""
return json.dumps(db.records.find_one({"id": id}))
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email."""
return mailer.send(to=to, subject=subject, body=body)
# Same signature as deepagents.create_deep_agent.
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-6",
tools=[lookup_record, send_email],
subagents=[{
"name": "researcher",
"description": "Looks up records and summarises them.",
"system_prompt": "Use lookup_record, then report.",
}],
)
@app.post("/run")
def run(prompt: str, authorization: str = Header(...)):
set_context(
user_token=authorization.removeprefix("Bearer ").strip(),
agent_name="my-deep-agent",
agent_tier="background",
)
result = agent.invoke({"messages": [{"role": "user", "content": prompt}]})
return {"result": result["messages"][-1].content}
No decorator on either tool, and nothing on the subagent spec. ACP is on the main agent, on researcher, and on the general-purpose subagent Deep Agents adds by itself. When the model delegates with task, the subagent’s lookup_record call is checked and recorded exactly like the parent’s would be, under the same session.
Why the plain middleware line is not enough
create_deep_agent assembles a separate middleware stack for each declarative subagent. The parent’s user-supplied middleware is merged in only for mode="fork" subagents. The auto-added general-purpose subagent inherits only parent middleware whose name matches one of its default slots, which ACPMiddleware never does.
So with create_deep_agent(..., middleware=[ACPMiddleware()]) the trail looks like this: one governed task row, then silence while the subagent reads, writes, and calls tools with no policy check and no audit row. It looks covered and is not.
That is verified against deepagents 0.7.13, not inferred. tests/test_deepagents.py drives a scripted model through main → task → subagent tool call with stock create_deep_agent and asserts ACP saw only task; the same script through the ACP wrapper asserts it saw task and the subagent’s tool. No model key needed to run it.
If you build with deepagents.create_deep_agent directly, wrap the subagents list instead:
from deepagents import create_deep_agent
from acp_langchain import ACPMiddleware
from acp_langchain.deepagents import govern_subagents
agent = create_deep_agent(
model=..., tools=[...],
middleware=[ACPMiddleware()],
subagents=govern_subagents(my_subagents),
)
govern_subagents adds ACPMiddleware to every declarative spec and inserts a governed general-purpose spec (same name, description, and prompt as the stock one, so the model’s view of task does not change) unless you supplied your own.
Compiled and remote subagents. A CompiledSubAgent (runnable=) or an AsyncSubAgent (graph_id=) is built elsewhere, so ACP cannot be injected here. They pass through unchanged and a UserWarning names each one. Register ACPMiddleware on those graphs where you build them.
What happens on every tool call
ACPMiddleware’s tool-call wrap hook POSTs to ACP’s/govern/tool-usewith the tool name, args, and the user JWT bound byset_context.- Deny → the tool function is never called. The model receives
"tool_error: <reason>"as the tool result, sees the denial, and adapts. - Allow → your function runs.
- Post-audit: ACP scans the output for PII / secrets.
redact→ the redacted version 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.
Filesystem tools (read_file, write_file, edit_file, ls, glob, grep), skill file reads, and task itself are tool calls, so they take the same path. Memory files loaded at startup are read outside any tool call and are not seen.
What Deep Agents already ships, and what this adds
Deep Agents is not short on controls of its own. Be clear about the overlap before adding a layer.
| Already in Deep Agents / LangChain | What it is | What ACP adds |
|---|---|---|
interrupt_on / HumanInTheLoopMiddleware |
The pause. Approve, edit, or reject a proposed call. Inherited by subagents. | The decision behind the pause, from one workspace policy, with the approval on the record. An approved call still passes the ACP check at execution. |
permissions (FilesystemPermission) |
allow / deny / interrupt rules for filesystem paths, per agent, in code. Inherited by subagents. | Policy on every tool, not just filesystem, keyed on the end user, changed in the workspace without a redeploy. |
PIIMiddleware |
Redacts PII in the message stream of one agent. | PII and secret detection on tool input and output, recorded as findings on the audit row. |
ToolCallLimitMiddleware, ModelCallLimitMiddleware |
Caps per run, in code. | Rate limits per user and per tool across every run and every harness. |
| Tracing (LangSmith or your own) | Records what happened. | Records what was decided, and why, before it happened, in one trail alongside Claude Code, Cursor, Codex, and CrewAI. |
If you run one Deep Agent and nothing else, the native controls cover a lot. The gap they leave is that every rule lives in that agent’s code, applies to that agent, and says nothing about which human triggered the call. ACP is the same control, applied from outside the harness, across all of them.
Composes with Deep Agents’ own controls
Keep interrupt_on and permissions. Wrap hooks nest, so the interrupt fires after the model proposes the call and ACP runs at execution:
agent = create_deep_agent(
model="anthropic:claude-sonnet-4-6",
tools=[lookup_record, send_email],
interrupt_on={"send_email": True}, # the pause
permissions=[FilesystemPermission(operations=["write"], paths=["/secrets/**"], mode="deny")],
)
Scope ACP when you need to:
from acp_langchain import ACPMiddleware
create_deep_agent(..., acp=ACPMiddleware(exclude=["ls", "glob", "grep"]))
Per-tier policy
set_context(agent_tier="...") controls the policy tier:
interactive— human at the keyboard, permissive default.subagent— invoked by another agent, no human in the immediate loop.background— autonomous, most restrictive.api— programmatic call from your backend.
Subagent tool calls carry the tier and session bound on the request; contextvars carry through the task call.
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. Deep Agents resolves model="anthropic:..." /
"openai:..." strings through the provider clients init() configures, so
one call covers the main agent and every subagent.
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.
Your coverage: interception ✓ (main agent + subagents) · proxy ✓ (model calls).
FAQ
Do I have to change my subagent specs? No. Pass them as you do today. The wrapper adds the middleware; your specs are not mutated.
I disabled the general-purpose subagent through a profile.
Pass general_purpose=False to govern_subagents, or supply your own spec with that name; the wrapper keeps yours and governs it.
What does the model see on a denial inside a subagent?
Same as the parent: "tool_error: <reason>" as the tool result. The subagent adapts and returns its final message to the parent as usual.
Does this touch my LLM traffic? Not by default. Opt in with the proxy plane above.
What if the ACP gateway is unreachable?
Fail-open: the tool proceeds, marked "fail-open" in the reason. Policy checks are never a single point of failure for the agent. (5s timeout, configurable via configure(timeout_s=...).)
Which versions?
acp-langchain >= 0.3, deepagents >= 0.7, langchain >= 1.3.3. The subagent behaviour described above is what deepagents 0.7.13 does; the characterisation test in the SDK repo will fail the day upstream starts propagating user middleware, which is the signal to drop the wrapper.