hi @Sami606713
I believe your mental model is right on all three points, with one architectural correction - for a chat-facing builder, maybe don’t hand the model a host shell at all. Make your langctl wrappers the only thing that mutates the workspace.
1. root_dir = workspace already works; the real question is whether you want a shell there
Mechanically, nothing forces root_dir to be a single pre-existing project. Point it at the workspace (parent of all projects), keep virtual_mode=True, and the agent can create /<new-project>/… freely:
FilesystemBackend.write() does resolved_path.parent.mkdir(parents=True, exist_ok=True) before opening the file, so writing /research-assistant/agent.py creates the directory (filesystem.py#L484-L515).
LocalShellBackend.execute() runs subprocess.run(..., shell=True, cwd=str(self.cwd)) where self.cwd is the resolved root_dir, so langctl new research-assistant lands as a sibling project (local_shell.py).
virtual_mode=True blocks .., ~ and absolute paths outside root_dir for the file tools only. Both the docs and the docstring say “virtual_mode=True provides no security with shell access enabled, since commands can access any path on the system” (Backends → LocalShellBackend).
So “single project” scoping is a convention, not a constraint; CompositeBackend is not needed to answer this question.
Where CompositeBackend does matter - and a gotcha. The docs recommend wrapping disk backends in a CompositeBackend so the agent’s internal artefacts (/large_tool_results/, /conversation_history/) don’t land in your real workspace (Backends → FilesystemBackend tip). But CompositeBackend routes file operations by prefix only; execute() always delegates to self.default and raises “Default backend doesn’t support command execution” otherwise (composite.py#L751-L779). If you want shell + a clean internal namespace, the LocalShellBackend must be the default and StateBackend the routes - not the other way round as in the docs’ FilesystemBackend example.
The architectural correction. Because this builder sits behind a chat UI, weigh the shell decision explicitly:
- Single-user local CLI, you trust every prompt → a workspace-rooted
LocalShellBackend is acceptable, with interrupt_on={"execute": True, ...} as the documented “strongly recommended” safeguard (HITL). Note interrupt_on requires a checkpointer.
- Anything shared, hosted, or with untrusted users → don’t. The production guide is blunt: “
FilesystemBackend and LocalShellBackend access the host directly. Don’t use them in deployed agents.” (Going to production). Use a sandbox backend (BaseSandbox subclass - Daytona/Modal/Runloop/…) per thread (Sandboxes).
- And independently of trust: if your invariant is “projects are created only through
langctl”, an unrestricted execute or writable write_file lets the model bypass the generator. Your own stated goal argues for removing them.
StateBackend as the builder’s scratch filesystem, validated langctl wrappers as the only host-mutating tools, optional read-only view of the workspace via CompositeBackend + FilesystemPermission:
from deepagents import FilesystemPermission, create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langchain.tools import tool
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.checkpoint.memory import InMemorySaver
WORKSPACE = "/home/me/agents" # absolute; parent dir of all langctl projects
@tool
def scaffold_project(name: str, template: str, provider: str) -> dict:
"""Create ONE new agent project with an approved langctl template."""
# In application code, not in the prompt:
# - validate `name` as a slug (no separators), allow-list `template`/`provider`
# - resolve destination, assert it stays under WORKSPACE and does not exist
# - subprocess.run(["langctl", "new", name, "--template", template, ...],
# cwd=WORKSPACE, env=MINIMAL_ENV, shell=False, timeout=...)
# - return {"path": f"/projects/{name}/", "files": [...], "stdout": ..., "exit_code": ...}
return run_validated_langctl_new(name, template, provider)
@tool
def sync_project(name: str) -> dict:
"""Run `langctl sync` for an existing project (regenerates langgraph.json / deps)."""
return run_validated_langctl_sync(name)
@tool
def verify_project(name: str) -> dict:
"""Import the generated graph and run the project's smoke tests; return stdout/stderr."""
# e.g. uv run python -c "from app import agent; agent.get_graph()" in a disposable env
return run_validated_verify(name)
client = MultiServerMCPClient({
"docs": {"transport": "http", "url": "https://docs.langchain.com/mcp"},
"reference": {"transport": "http", "url": "https://reference.langchain.com/mcp"},
})
docs_tools = await client.get_tools() # async ⇒ use builder.ainvoke(...)
backend = CompositeBackend(
default=StateBackend(), # scratch + /large_tool_results/ + /conversation_history/
routes={
"/projects/": FilesystemBackend(root_dir=WORKSPACE, virtual_mode=True),
},
)
builder = create_deep_agent(
model="anthropic:claude-sonnet-4-5",
backend=backend,
tools=[*docs_tools, scaffold_project, sync_project, verify_project],
permissions=[
# built-in file tools may READ generated projects but never WRITE them
FilesystemPermission(operations=["write"], paths=["/projects/**"], mode="deny"),
],
system_prompt=(
"Design the agent using the docs/reference tools. Create or change host "
"projects only via scaffold_project / sync_project; never claim files "
"the tools did not create. Always finish with verify_project."
),
interrupt_on={
"scaffold_project": {"allowed_decisions": ["approve", "reject"]},
"sync_project": True,
},
checkpointer=InMemorySaver(), # required for interrupt_on; durable saver in a service
)
permissions= governs only the built-in filesystem tools (Permissions); MCP and custom tools enforce their own policy - that’s why the langctl wrappers validate arguments in code. Prompt text is not authorization.
- Have
scaffold_project return the virtual path (/projects/<name>/) plus a file listing, so the agent’s follow-up read_file/glob calls line up with the /projects/ route.
- Split by capability (
scaffold, sync, verify) rather than one “run langctl” tool - each is a clean HITL checkpoint.
- Dependency installation runs third-party build hooks; do
uv sync in a disposable environment.
- Docs chunks are large; if the main loop’s context fills up, put the docs tools in a subagent with a tight prompt. Large tool results are already offloaded to
/large_tool_results/, which is why that stays on StateBackend.
2. Scope of the hosted docs MCP server - use it, don’t rely on it
There are two hosted endpoints, documented at Use these docs programmatically:
| Name |
URL |
Documented scope |
docs-langchain |
https://docs.langchain.com/mcp |
“Conceptual guides, how-tos, tutorials, and product docs for LangChain, LangGraph, and LangSmith” |
reference-langchain |
https://reference.langchain.com/mcp |
“API reference: classes, methods, parameters, and signatures for all LangChain packages” |
Deep Agents pages live under docs.langchain.com/oss/python/deepagents/* and its API reference under reference.langchain.com/python/deepagents/…, so in practice both servers return them today - and the docs-langchain server is exactly the one used in the official MultiServerMCPClient example (“public and does not require an API key”, MCP → HTTP).
But treat that as observed behaviour, not a contract:
- The documented scope sentence names LangChain, LangGraph and LangSmith; Deep Agents isn’t explicitly promised, and there’s no SLA or version pinning. Don’t encode “every Deep Agents page is always indexed” anywhere.
- The page itself recommends connecting both servers “for the full picture”. For a code generator the reference server is what stops the model hallucinating
create_deep_agent kwargs.
- Pin package versions in your
langctl templates and make templates authoritative. Docs search informs the design; it must not replace versioned templates + verify_project. Otherwise you hit the classic failure: docs describe the current release, the generated project installs an older one.
- The tools are search + page-read over chunks;
get_tools() is async, so the builder runs via ainvoke.
3. Builder and generated agent in separate runtimes - correct
builder run → validated langctl tool → project files → verify_project
→ (later) langgraph dev / deploy
→ independent agent runtime
Neither subgraphs nor “dynamic graph registration” fits:
- Subgraphs compose executable behaviour inside a parent graph invocation, sharing its persistence model (Use subgraphs). They’re for the builder calling the child in-process - not your case.
- Graph registration is static. The Agent Server reads
langgraph.json "graphs": {"name": "./file.py:agent"} at startup (Application structure); there’s no API to register a graph into a running server. langctl new writing langgraph.json and a person later running langgraph dev is exactly the intended flow.
- Evaluating model-generated Python and registering it in the builder’s own process would collapse the security/lifecycle boundary you deliberately created. Avoid it.
- If you later want the builder to smoke-test the child end-to-end, the primitive is
RemoteGraph (Use RemoteGraph): start the generated project as its own langgraph dev process and call it over HTTP from a builder tool. Separate runtimes, real acceptance test.
4. Prior art
There is no official “meta-agent scaffolds a new agent project” example and no special framework primitive for it yet. Closest pieces:
deepagents-cli - a LocalShellBackend coding agent with HITL; the reference for a locally-scaffolding agent (CLI).
examples/better-harness, examples/content-builder-agent, examples/llm-wiki in the deepagents repo - agents that produce artefacts through a toolchain.
langgraph new in the LangGraph CLI - the first-party template scaffolder (CLI); langctl new is the natural thing to hand a deep agent as a tool.
- Deep Agents overview, MCP guide, Backends.