Deep agent that scaffolds new LangChain/LangGraph agents on request — LocalShellBackend + langctl as tools?

Problem

I’m building a CLI (langctl) that scaffolds LangChain/LangGraph agent
projects. I want to add a “builder” deep agent behind a chat UI that can
take a request like “make me a research assistant” and actually produce
a new, working agent project — not just talk about it.

Two things I haven’t found a clean answer for in the docs:

  1. Scoping LocalShellBackend across a workspace, not one project.
    LocalShellBackend(root_dir=…) 1 is documented as scoped to a single
    root_dir, which is the right default for editing one existing project.
    But “create a new sibling agent” means the backend needs to create a
    new subdirectory next to others, not just read/write inside one that
    already exists. Is there a recommended pattern for “root_dir is a
    workspace containing multiple projects, and the agent may create new
    ones” — or is the intended answer to compose this with a
    CompositeBackend and route project creation through a narrower,
    dedicated path?

  2. Docs coverage via the hosted MCP server.
    Confirmed the hosted docs MCP endpoint 2 already covers LangChain,
    LangGraph, LangSmith, and deepagents itself under one connection — so a
    deep agent gets doc-search across the whole ecosystem for free via
    MultiServerMCPClient, no separate integration per product. Wanted to
    confirm that’s the intended scope of that endpoint (vs. me assuming
    coverage that isn’t actually guaranteed to stay complete).

What I’m trying to build

  • create_deep_agent(model=, backend=, tools=, system_prompt=) 3 as the
    builder agent.
  • Tools: the docs MCP tools above, plus my own CLI’s commands
    (project scaffolding, dependency sync) wrapped as @tool functions so the
    agent generates projects through my own generators instead of freehand
    file writes.
  • The builder and the agent it creates never share a runtime — the created
    agent only runs later, as its own separate process, when a person starts
    it. Wanted to sanity-check that’s the right mental model rather than
    something LangGraph has a more direct primitive for (subgraphs? dynamic
    graph registration at runtime?).

Any pointers to prior art (something like a “meta-agent that scaffolds
other agents” example) or docs I’ve missed would help a lot.


langctl — the CLI this is for:
GitHub: GitHub - Sami606713/agent_cli: Scaffold, run & deploy production LangChain agents — frontend and agent in one command. Built-in proxy, 3 chat UIs, zero CORS. · GitHub
PyPI: Client Challenge

— Sami Ullah, LinkedIn: /in/sami-ullah-6326b9265, X: @0xAgentHQ

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.

Thanks, this is exactly the correction I needed, especially point 1.

You’re right that virtual_mode was giving me false confidence: execute()
ignoring it entirely means a workspace-rooted LocalShellBackend was never
actually the safety boundary I thought it was. Splitting into
StateBackend (scratch) + three validated tools (scaffold_project /
sync_project / verify_project) that wrap langctl with checks in code,
not prompt text, is a much better match for “projects are created only
through langctl” than what I had.

Adding reference-langchain alongside docs-langchain now — good catch that
I was only citing conceptual docs, which is exactly the gap that lets a
model hallucinate kwargs.

Going to prototype the CompositeBackend(default=LocalShellBackend or
StateBackend, routes={“/projects/”: FilesystemBackend(…)}) split you
described and report back. One follow-up: for verify_project, is running
uv run python -c "from app import agent; agent.get_graph()" in a
disposable env the recommended smoke test, or is there a first-party
“validate this generated project” check I should be calling instead of
hand-rolling one?

hi @Sami606713

there are three first-party layers you should call instead of (or before) hand-rolling, and your agent.get_graph() one-liner is the weakest of the options - it proves less than you think. Build verify_project as a ladder:

Level What it proves First-party? Cost
0. langgraph validate langgraph.json is well-formed, graph specs resolve to module:attr strings, no unknown keys yes (CLI ≥ 0.4.21, undocumented) ms
1. uv sync --frozen + python -c "import app" deps resolve, module imports, graph compiles partly (uv) seconds
2. langgraph dev --no-browser + SDK assistants.get_graph() the Agent Server can load the graph the way it will be deployed yes ~5-10 s
3. langgraph up same, inside the real container image yes; the docs’ pre-deployment checklist minutes, needs Docker

I wouldn’t use root_dir=workspace as the builder’s normal mutable backend. Split allocation from mutation: give it one workspace-level scaffold_project tool that validates the slug, refuses an occupied unregistered path, records the project in a manifest, and returns a project handle. Then instantiate a fresh backend rooted at exactly that project.

Make the allocator re-entrant too, since agents retry tools: the same registered slug should return the same handle rather than creating a second directory. The builder can enumerate siblings from the manifest, so nothing else needs a workspace-wide mutable handle.