Ahsan Iqbal

Deep Agents, from the inside · Part 2

Your first deep agent, and the prompt it really sees

In part 1 we placed Deep Agents in the stack. Now we build one. We'll go from pip install to a paused run waiting for a person, then open up the one function you call, and finish by watching the system prompt the model receives get assembled, layer by layer.

· 11 min read · by Ahsan Iqbal

Part 2 of 16 in Deep Agents, from the inside
  1. What Deep Agents is, and where it came from
  2. Your first deep agent, and the prompt it really sees
  3. How a tool call works, and one turn of the loop
  4. How middleware wraps every call17 Nov 2026
  5. The tools the model gets, from files to MCP and planning20 Nov 2026
  6. State, checkpoints, and why big results go to files24 Nov 2026
  7. Summarization, overflow recovery and prompt caching27 Nov 2026
  8. Delegation with the task tool and your own subagents1 Dec 2026
  9. Parallel, async and streamed subagents4 Dec 2026
  10. Storage, backends and routing8 Dec 2026
  11. Interrupts, approvals and filesystem permissions11 Dec 2026
  12. Skills and memory15 Dec 2026
  13. Profiles, rubric grading and writing your own middleware18 Dec 2026
  14. Recipes, guardrails and the code interpreter22 Dec 2026
  15. Shipping it, with deployment, observability and the usual gotchas26 Dec 2026
  16. Reading the repo, with its tests, a reading path and a glossary29 Dec 2026

Written against deepagents 0.7.35, langchain 1.4.10 and langgraph 1.2.20, read from main at commit 31649f8 on 12 Nov 2026. The project ships several releases a week, so if something here disagrees with the code you have, trust the code.

Five minutes to a working agent

Installing the package pulls in LangChain, LangChain's Anthropic integration and LangGraph. The default model is Anthropic's, so you need an Anthropic key, unless you pass a different "provider:model" string, which we'll get to at the end.

pip install deepagents          # or: uv add deepagents
export ANTHROPIC_API_KEY=...

A first run

The smallest useful agent needs only a few lines. We give it a model, a one-line system prompt that asks it to save its answer to a file, and a question.

from deepagents import create_deep_agent

agent = create_deep_agent(
    model="anthropic:claude-sonnet-4-6",
    system_prompt="You are a careful analyst. Save your final answer to /answer.md.",
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "Compare event sourcing and CRUD for an audit-heavy app."}]}
)

print(result["messages"][-1].text)              # the final reply
print(result["files"]["/answer.md"]["content"])  # files live in state by default

The last two lines deserve a second look.

First, result is not a reply object. It is the graph's final state, the same dictionary any LangGraph graph returns. The conversation is in result["messages"], and the last message is the agent's answer.

Second, the file the agent wrote is sitting right there in result["files"]. That's because the default storage, StateBackend, keeps the virtual filesystem inside graph state, in a channel called files, keyed by absolute path. Each value is a small dictionary (FileData) with the content, an encoding ("utf-8" or "base64") and optional timestamps. Nothing touched your disk. Part 10 shows how to route files to disk, a database or a sandbox instead.

Giving the agent files to start with

Because files are just state, you can seed them on the way in. This is how you hand an agent a document to work on without pasting it into the prompt.

from deepagents.backends.utils import create_file_data

agent.invoke({
    "messages": [{"role": "user", "content": "Review /spec.md and list the risks."}],
    "files": {"/spec.md": create_file_data(open("spec.md").read())},
})

The difference matters more than it looks. Paste a long spec into the message and it sits in the context window for the whole run. Seed it as a file and the agent can grep it, read the section it needs, and leave the rest out of the window. That's the "offload" and "retrieve" moves from part 1, working on day one.

Watching it work, subagents included

Because the agent is a normal LangGraph graph, every stream mode works. There's one flag to know, subgraphs=True. Without it you only see the main agent. With it, each chunk arrives as a (namespace, data) pair, and a subagent's namespace starts with "tools:", because subagents run inside the main agent's tools node.

for namespace, update in agent.stream(
    {"messages": [{"role": "user", "content": "Research and summarize X."}]},
    stream_mode="updates",
    subgraphs=True,
):
    who = "main" if not namespace else namespace[0]   # e.g. "tools:3f9c…"
    print(who, list(update))

Pausing for a person

The last piece of the quickstart is the one that makes agents safe to point at real systems. Say you want to approve every file the agent writes. You list the tool in interrupt_on, and you give the agent a checkpointer plus a thread_id.

from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command

agent = create_deep_agent(
    interrupt_on={"write_file": True},   # pause before any file write
    checkpointer=InMemorySaver(),        # required to pause and resume
)
cfg = {"configurable": {"thread_id": "t-1"}}
out = agent.invoke({"messages": [{"role": "user", "content": "Draft /plan.md"}]}, cfg)
if "__interrupt__" in out:                    # the run is paused
    agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), cfg)

Why is the checkpointer required? Because a pause is not a thread sleeping somewhere. The run actually stops, its state is saved under that thread_id, and invoke returns with an __interrupt__ key describing what's waiting. Your app can show that to a person and take seconds or days to get an answer. When it calls invoke again with a Command(resume=…) and the same thread id, LangGraph loads the saved state and carries on. Part 11 takes this apart step by step. In async code, use await agent.ainvoke(...) or agent.astream(...), which async subagents need.

The one function you call

Everything above went through create_deep_agent(). Every argument is optional and keyword-friendly, and the function returns a compiled LangGraph graph with the 9,999-step recursion limit and tracing metadata already bound.

The full list is long, so it helps to sort it before reading it. There are really five groups. They are what thinks (model, system_prompt, response_format), what it can do (tools, subagents, skills), where things live (backend, memory, store, checkpointer), what needs permission (interrupt_on, permissions) and plumbing (middleware, the schemas, name, debug, cache). With that map in mind, the table reads more easily.

ArgumentDefaultWhat it does
modelNoneA "provider:model" string or a BaseChatModel. A string is resolved with init_chat_model plus any provider profile. None is deprecated (it warns) and falls back to ChatAnthropic("claude-sonnet-4-6"). The model also selects the harness profile.
toolsNoneYour tools, added to the built-ins. The general-purpose subagent gets them too, and so does any declarative subagent that doesn't set its own tools.
system_promptNoneYour instructions, a str or a SystemMessage. They come first in the final prompt.
middleware()Extra middleware. If an entry's name matches a built-in, it replaces that one in place. Otherwise it is inserted after the core stack and before the tail.
subagentsNoneA list of specs. A dict with graph_id is an async (remote) subagent, one with runnable is a compiled subagent, and anything else is a declarative SubAgent.
skillsNoneSkill source directories on the backend. Adds SkillsMiddleware to the main agent and the general-purpose subagent.
memoryNonePaths of AGENTS.md-style files on the backend. Adds MemoryMiddleware.
permissionsNoneFilesystemPermission rules for the file tools. "interrupt" rules add human-in-the-loop entries automatically. Subagents inherit them.
backendStateBackend()Where files live and whether execute exists. One backend instance is shared by every stack, subagents included. A factory callable is rejected with TypeError.
interrupt_onNoneTool name → True, False or an InterruptOnConfig. Adds HumanInTheLoopMiddleware. Your entries override ones generated from permissions. Declarative subagents inherit it.
response_formatNoneStructured output for the main agent. The result then has a structured_response key.
state_schemaDeepAgentStateYour own state class, which must subclass DeepAgentState. It is also passed to declarative subagents.
context_schemaNoneThe type of the runtime context you pass with invoke(..., context=...), readable in tools and middleware as runtime.context.
checkpointerNoneNeeded for threads, interrupts and resumes. Subagents save under the parent's checkpointer at tools:… namespaces.
storeNoneA LangGraph BaseStore. StoreBackend finds it at call time, so pass it here when you use one.
nameNoneThe graph's name, which also becomes the lc_agent_name tracing metadata.
debug, cacheFalse, NonePassed straight to create_agent.

Three rows hide behaviour that bites people later, so I'll call them out.

  • One backend for everyone. The backend instance you pass is shared by the main agent and every subagent. A subagent that writes /notes.md writes it where the main agent can read it. That's usually what you want, and it's how subagents hand back large results.
  • Middleware replaces by name. If you pass middleware whose name matches a built-in, yours takes its place in the same position rather than being added twice. This is the intended way to swap a piece of the harness, and part 13 uses it.
  • Explicit beats generated. permissions can generate approval rules for you, but anything you write in interrupt_on wins.

Mistakes it catches before you run

Some configuration errors are caught at build time, which is the cheapest place to find them. If create_deep_agent() throws, it's usually one of these.

  • ValueError for duplicate subagent names, an invalid mode, skills on a forked subagent, a bad async spec, permission paths that don't start with / or contain .., or a profile that excludes required middleware.
  • NotImplementedError for permissions combined with a backend that can run shell commands (a shell can reach any path, so path rules can't be enforced, as part 11 explains), or a ~ in a permission path.
  • TypeError for a backend factory passed instead of an instance.
  • AssertionError from create_agent for two middleware with the same name.

What the model actually sees

A good question to ask of any harness is what exactly sits in front of the model when it is called. With Deep Agents the answer has fewer surprises than it used to. There's no hidden base prompt any more (BASE_AGENT_PROMPT was deprecated in 0.7 and nothing uses it). The model sees exactly two things, a system message and a tool list.

What makes it interesting is that they're built at two different times. Part is fixed when you call create_deep_agent(). The rest is appended by middleware on every model call, each section as a new text block. The animation below assembles both, in order. Press play, or tap any numbered stop to jump straight to it.

System message top to bottom, in order your system_promptcreate_deep_agent(system_prompt=…) profile base_system_promptharness profile (unset by the built-ins) profile system_prompt_suffixe.g. Sonnet 4.6 adds parallel tool calls fixed at build time ↑ · appended on every model call ↓ ## Skills SystemSkillsMiddleware, if skills= <agent_memory> + <memory_guidelines>MemoryMiddleware, if memory= ## Shell paths vs. virtual pathsonly with execute + routed backend Tools ls read_file write_file edit_file glob grep delete* execute* taskdescription lists your subagents your tools=[…]descriptions can be overridden − excluded toolsprofile excluded_tools, removed last * hidden when the backend can't delete or run commands
The final request has two parts, a system message built in layers and a tool list. Press play to watch it assemble.
  1. Your system_prompt goes firstWhatever you passed comes at the very top. If you passed a SystemMessage rather than a string, its content blocks are kept exactly as they are, including any cache_control markers you set.
  2. The model's harness profileNext comes the profile picked by your model string, with its base_system_prompt (none of the built-in profiles set one) and its system_prompt_suffix. For the default Sonnet 4.6, the suffix adds guidance on calling independent tools in parallel and on checking work. This is the only "harness prompt" left.
  3. Tools are registered onceOver on the tool side, the file tools from FilesystemMiddleware and your own tools= are registered when the agent is built. A profile can override any tool's description, which matters because a tool's description is a prompt in its own right.
  4. The task tool carries the subagentsSubagents don't get a section in the system prompt. Instead, the task tool's description lists each subagent's name and when to use it. The model learns about delegation the same way it learns about any tool.
  5. Below the dashed line, per-call sectionsEverything above the line was fixed at build time. Everything below is appended on every model call. First, if you passed skills=, SkillsMiddleware adds a skills menu with one line per skill. Each skill is read in full only when needed.
  6. Memory, inside <agent_memory>If you passed memory=, MemoryMiddleware appends the contents of your AGENTS.md files inside an <agent_memory> block, with guidelines on how to use and update them. On Anthropic models it also marks a cache breakpoint here.
  7. A section that appears in one case onlyFilesystemMiddleware adds text only when execute is active and the backend routes paths to different stores. Then the model needs telling that shell paths and virtual paths differ.
  8. Exclusions are applied lastFinally, any tools the profile excludes are removed, and delete and execute are hidden if the backend can't do them. What's left is exactly what the model receives.

Two practical rules fall out of this picture.

Order is stable, and that's on purpose. Your prompt, then the profile's base prompt, then its suffix, joined by blank lines. The parts that never change sit at the top, and the parts that can change (skills, memory) come after. That ordering is what lets prompt caching work, since a cache can only reuse an unchanged prefix. Part 7 comes back to this.

If you want to know why the model did something, look at the tool descriptions too. The subagent list lives in the task tool, not the system prompt, and profiles can rewrite descriptions. A surprising amount of agent behaviour is decided there. (SubAgentMiddleware appends to the system prompt only if you build it yourself and give it a system_prompt.)

Choosing a model

Any LangChain chat model that supports tool calling works. You can pass a string and let Deep Agents build the model, or build it yourself when you need specific parameters.

from langchain.chat_models import init_chat_model
from langchain_anthropic import ChatAnthropic

agent = create_deep_agent(model="anthropic:claude-sonnet-4-6")                     # string
agent = create_deep_agent(model=init_chat_model("openai:gpt-5.1", temperature=0))  # with params
agent = create_deep_agent(model=ChatAnthropic(model="claude-sonnet-4-6", max_tokens=16_000))

Which model, though? A deep agent leans on a few capabilities much harder than a chatbot does, so judge models on those rather than on general benchmarks.

  • Reliable tool calling comes first. A single task can mean dozens of calls, so a small per-call error rate compounds. Parallel tool calls and a large context window come next.
  • The window size drives summarization. Summarization triggers at 85% of the window, and it reads the window size from the model's profile (max_input_tokens). If the model has no profile, a fixed fallback of 170,000 tokens is used, which may be wrong for your model.
  • Always pass a model. Leaving it out still works, but it's deprecated and warns.
  • Mix models across agents. A strong model for the coordinator and a cheaper, faster one for narrow research subagents is a common and effective split.
  • Profiles follow the string. Provider profiles change how some models are built. OpenAI models use the Responses API by default, and OpenRouter and NVIDIA get their required headers. Harness profiles are matched by the same string, so "anthropic:claude-sonnet-4-6" picks up its prompt suffix automatically. With a model object, the key is derived from its provider and model name.
  • Open-weight models work too, through hosted providers such as OpenRouter, Fireworks and Baseten. Nemotron 3 Ultra even has a dedicated profile. Test how well a model handles long tool-calling loops before relying on it.

Next

We've built an agent and seen exactly what it sends to the model. But we've been treating "the model calls a tool" as a black box. In part 3 we take that apart from first principles, then follow one request all the way around the loop, through the six points where middleware can step in.

I build production agent harnesses, which means orchestration, cited retrieval, memory and approval-gated actions. This series reads LangChain's Deep Agents the way I read any harness, starting from the source. More about me · RSS