Back to blog
Guides8 min read

Building LangChain Deep Agents: the config object is the agent

How createDeepAgent turns a prompt, a tool list and a backend into a long-horizon agent — what subagents inherit, why skills and memory are files, and which backend makes them reachable.

A harness, not a loop

Most agent code starts as a while loop around a model call. You send messages, the model asks for a tool, you run it, you append the result, you go again. It works until the task is long enough that the loop's context fills up with tool output, or the task splits into parts that would be better handled separately.

Deep Agents is LangChain's answer to that second stage. It is a harness on top of LangGraph that ships with the pieces a long task needs already wired: a planning tool (write_todos), a virtual filesystem the agent reads and writes as it works (ls, read_file, write_file, edit_file, glob, grep), delegation to subagents through a task tool, and middleware for summarisation and human approval. You configure it with one call:

import { createDeepAgent } from "deepagents"

const agent = createDeepAgent({
  model: "anthropic:claude-sonnet-4-6",
  systemPrompt,
  tools: [searchWeb],
  subagents: [webResearcher],
  skills: ["/skills/"],
  memory: ["/memories/AGENTS.md"],
  checkpointer: new MemorySaver(),
  interruptOn: { send_email: true },
})

That object is the whole agent. There is no directory it scans and no registration step — which is exactly why the free LangChain Deep Agents generator opens its preview on src/agent.ts rather than on a file tree. The tree is a convenience so each tool can be edited on its own; the config is the thing that matters.

The model id carries the provider

createDeepAgent takes a model as a string and resolves it through initChatModel, which means the string needs a provider prefix: anthropic:claude-sonnet-4-6, openai:gpt-5.5, google-genai:gemini-3.6-flash. The bare model name is not enough.

The prefix does more than route the call. It decides which provider package has to be installed (@langchain/anthropic, @langchain/openai, @langchain/google-genai) and which environment variable initChatModel will read (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY). Get it wrong and the failure is a missing module at runtime, not a validation error up front — which is why the generator reports a model id without a prefix rather than quietly emitting a package.json that cannot install.

Tools are tool() with a Zod schema

A custom tool is the tool() helper from langchain, a Zod schema and a handler:

import { tool } from "langchain"
import { z } from "zod"

const searchWeb = tool(
  async (input) => JSON.stringify(await search(input.query)),
  {
    name: "search_web",
    description: "Search the web for a query and return the top results.",
    schema: z.object({
      query: z.string().describe("The search query, as the user would type it."),
    }),
  }
)

Two fields do more work than they look like they do. The description is the entire basis on which the model decides to call this tool rather than another, or rather than answering from memory. And every .describe() on a parameter is the only thing telling the model what belongs in that field — the model never sees this file, only the JSON schema derived from it.

There is one trap specific to Deep Agents: the harness already has tools named ls, read_file, write_file, edit_file, glob, grep, execute, task and write_todos. A custom tool with one of those names does not add a capability; it replaces the built-in with your stub, and nothing tells you. The agent just loses its filesystem, or its ability to delegate, and carries on. Pick another name.

What a subagent inherits — and what it never does

A subagent is a plain spec in the subagents array:

const webResearcher: SubAgent = {
  name: "web-researcher",
  description: "Investigate one question on the web and report back with sources.",
  systemPrompt: "Gather evidence first, then summarise it with sources.",
  model: "openai:gpt-5.5",
  tools: [fetchPage],
}

The parent reaches it through the built-in task tool, which blocks until the subagent finishes and returns its final message. What the subagent receives is worth being precise about:

  • Tools and model inherit by default. Leave tools out and the subagent gets the parent's. But when you do specify tools, the array replaces the inherited set entirely — tools: [] is a subagent with nothing but the filesystem.
  • The system prompt never inherits. systemPrompt is required. A subagent with an empty one is an agent with no role.
  • Skills do not inherit unless you pass skills: ["/skills/"] on the spec. Only the built-in general-purpose subagent gets the parent's skills for free.
  • The conversation does not transfer. The subagent sees its own prompt plus the task description the parent wrote. Every file path, error message and decision it needs has to be restated in that description.

Deep Agents adds a general-purpose subagent on its own unless you supply one by that name. Naming yours general-purpose replaces it rather than sitting beside it — allowed, but worth meaning.

Skills and memory are files in a filesystem the agent may not have

Both skills and memory are paths into the agent's virtual filesystem, and both load progressively.

A skill is skills/<name>/SKILL.md. Its frontmatter name and description reach the system prompt at startup; the body is read only when a task matches the description. The spec is strict: the name is lowercase alphanumeric with hyphens, at most 64 characters, and must equal the directory — which is why the generator has one name field that writes to both.

---
name: citations
description: Cite sources when reporting findings.
---

Quote the section you relied on, with a link.

Memory is memory: ["/memories/AGENTS.md"]: the file is preloaded into the system prompt on every run, and the agent edits it with edit_file when it learns something. Whether those edits survive the conversation is the backend's decision, not the memory option's.

Here is the part that catches people. The default backend is StateBackend, which keeps files in the LangGraph thread's state. It has no disk. A skills/ directory sitting in your project is invisible to it unless something reads those files in and passes them as files on the first invoke:

await agent.invoke(
  { messages: [{ role: "user", content: question }], files: seedFiles() },
  { configurable: { thread_id: "demo" } }
)

The generator emits a src/seed.ts that does exactly that — and omits it when you pick FilesystemBackend or LocalShellBackend, because those resolve /skills/ under their root directory and need nothing. If your skills never fire, check the backend before you check the descriptions.

Choosing a backend

Three are worth knowing:

  • StateBackend (default): files live in the thread. Nothing touches disk. Good for an agent whose "files" are scratch space.
  • FilesystemBackend({ rootDir, virtualMode: true }): a real directory. virtualMode blocks .., ~ and absolute paths outside the root; the docs say to turn it off cautiously, because then the model can reach the whole disk.
  • LocalShellBackend({ workingDirectory }): the filesystem plus an execute tool. The agent runs shell commands as the user running the process. Right for a coding agent in a sandbox, wrong almost everywhere else.

Durable memory is a routing decision on top of whichever base you picked:

backend: new CompositeBackend(new StateBackend(), {
  "/memories/": new StoreBackend({ namespace: (rt) => [rt.serverInfo.assistantId] }),
})

Everything under /memories/ now lives in a StoreBackend that persists across threads, namespaced per assistant or per user. The rest stays where it was.

The checkpointer is not optional once a tool pauses

interruptOn is human-in-the-loop. { send_email: true } pauses the run before send_email executes and hands the pending call to a person, who may approve, edit the arguments, reject with feedback, or respond with a result of their own. A subset of those is { allowedDecisions: ["approve", "reject"] }.

A paused run has to be persisted somewhere to be resumed, and that somewhere is the checkpointer. Without one, createDeepAgent throws on the first interrupt. The resume is a Command:

while (result.__interrupt__?.length) {
  const decisions = result.__interrupt__[0].value.actionRequests.map(() => ({ type: "approve" }))
  result = await agent.invoke(new Command({ resume: { decisions } }), config)
}

The generated entry point auto-approves so the demo completes — which is the first line to replace before anyone relies on the gate.

Where to start

npm install deepagents langchain @langchain/core

Then a createDeepAgent call with a prompt and one tool. If you would rather see the whole project before writing it — the config, the subagent specs, the SKILL.md files, the seeding script the backend needs — the LangChain Deep Agents generator produces it as a downloadable .zip, free and without an account, and reports the traps above next to the row that triggers them instead of letting them ship.

The same form exists for the Claude Agent SDK, the OpenAI Agents SDK and Google ADK, and the MCP server generator builds the servers any of them can mount.

Target-specific scaffolding

Continue this learning path

Permissions, manifests, capabilities, agent projects and MCP servers for browser extensions, desktop, mobile, and backend agents.

Explore platform generators

Design your application

Design Web, Desktop, Mobile, Terminal, or Extension projects with a target-aware canvas and exporter.

Open projects

We'd like to use Google cookies to understand how Nodlume is used and to measure our advertising. Nothing loads until you choose, and declining does not affect anything in the app.