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.
Part 2 of 16 in Deep Agents, from the inside
- What Deep Agents is, and where it came from
- Your first deep agent, and the prompt it really sees
- How a tool call works, and one turn of the loop
- How middleware wraps every call17 Nov 2026
- The tools the model gets, from files to MCP and planning20 Nov 2026
- State, checkpoints, and why big results go to files24 Nov 2026
- Summarization, overflow recovery and prompt caching27 Nov 2026
- Delegation with the task tool and your own subagents1 Dec 2026
- Parallel, async and streamed subagents4 Dec 2026
- Storage, backends and routing8 Dec 2026
- Interrupts, approvals and filesystem permissions11 Dec 2026
- Skills and memory15 Dec 2026
- Profiles, rubric grading and writing your own middleware18 Dec 2026
- Recipes, guardrails and the code interpreter22 Dec 2026
- Shipping it, with deployment, observability and the usual gotchas26 Dec 2026
- 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.
| Argument | Default | What it does |
|---|---|---|
model | None | A "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. |
tools | None | Your 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_prompt | None | Your 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. |
subagents | None | A 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. |
skills | None | Skill source directories on the backend. Adds SkillsMiddleware to the main agent and the general-purpose subagent. |
memory | None | Paths of AGENTS.md-style files on the backend. Adds MemoryMiddleware. |
permissions | None | FilesystemPermission rules for the file tools. "interrupt" rules add human-in-the-loop entries automatically. Subagents inherit them. |
backend | StateBackend() | 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_on | None | Tool name → True, False or an InterruptOnConfig. Adds HumanInTheLoopMiddleware. Your entries override ones generated from permissions. Declarative subagents inherit it. |
response_format | None | Structured output for the main agent. The result then has a structured_response key. |
state_schema | DeepAgentState | Your own state class, which must subclass DeepAgentState. It is also passed to declarative subagents. |
context_schema | None | The type of the runtime context you pass with invoke(..., context=...), readable in tools and middleware as runtime.context. |
checkpointer | None | Needed for threads, interrupts and resumes. Subagents save under the parent's checkpointer at tools:… namespaces. |
store | None | A LangGraph BaseStore. StoreBackend finds it at call time, so pass it here when you use one. |
name | None | The graph's name, which also becomes the lc_agent_name tracing metadata. |
debug, cache | False, None | Passed straight to create_agent. |
Three rows hide behaviour that bites people later, so I'll call them out.
- One backend for everyone. The
backendinstance you pass is shared by the main agent and every subagent. A subagent that writes/notes.mdwrites 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.
permissionscan generate approval rules for you, but anything you write ininterrupt_onwins.
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.
ValueErrorfor 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.NotImplementedErrorfor 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.TypeErrorfor a backend factory passed instead of an instance.AssertionErrorfromcreate_agentfor 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.
- Your
system_promptgoes firstWhatever you passed comes at the very top. If you passed aSystemMessagerather than a string, its content blocks are kept exactly as they are, including anycache_controlmarkers you set. - 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 itssystem_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. - Tools are registered onceOver on the tool side, the file tools from
FilesystemMiddlewareand your owntools=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. - The
tasktool carries the subagentsSubagents don't get a section in the system prompt. Instead, thetasktool'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. - 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=,SkillsMiddlewareadds a skills menu with one line per skill. Each skill is read in full only when needed. - Memory, inside
<agent_memory>If you passedmemory=,MemoryMiddlewareappends the contents of yourAGENTS.mdfiles inside an<agent_memory>block, with guidelines on how to use and update them. On Anthropic models it also marks a cache breakpoint here. - A section that appears in one case only
FilesystemMiddlewareadds text only whenexecuteis active and the backend routes paths to different stores. Then the model needs telling that shell paths and virtual paths differ. - Exclusions are applied lastFinally, any tools the profile excludes are removed, and
deleteandexecuteare 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.