A deep research agent built with Deep Agents (LangChain 1.0 + LangGraph). Ask it a research question; it plans the work, delegates focused web searches to a subagent, synthesizes a cited answer, keeps durable findings across sessions, and asks for your approval before writing files or running commands.
| Capability | How | Where |
|---|---|---|
| Planning | Built-in write_todos (always on in Deep Agents) |
— |
| Web search | Tavily (tavily_search) |
deep_research/tools.py |
| Subagent orchestration | A researcher subagent, delegated to via the task tool |
deep_research/subagents.py |
| Persistent memory | SqliteStore behind a /memories/ route (cross-session) |
deep_research/agent.py |
| Durable thread state + interrupts | SqliteSaver checkpointer (survives restarts) |
deep_research/agent.py |
| Human-in-the-loop | interrupt_on gates write_file / edit_file / execute |
deep_research/agent.py + cli.py |
| Browser UI | Streamlit chat with a live work log and in-page approvals | streamlit_app.py + deep_research/webui.py |
| Observability | LangSmith tracing via env vars | .env |
Deep Agents separates two kinds of state, and this project uses a disk-backed option for each so everything survives a restart with no database server:
- Checkpointer (
SqliteSaver) — the conversation, todo list, and any pending approval for a giventhread_id. Stored in.deep_research/checkpoints.sqlite. - Store (
SqliteStore) — long-term memory shared across every thread. ACompositeBackendroutes only the/memories/path prefix here; all other agent files stay in the ephemeral (but checkpointed) per-thread state. Stored in.deep_research/memories.sqlite.
So a fact the agent writes to /memories/topic.md in one session is readable in
the next; a scratch draft it writes to /report.md lives only in that thread.
Requires Python ≥ 3.11 and uv.
# 1. Install dependencies into a local venv
uv sync
# 2. Provide credentials
cp .env.example .env
# then edit .env and fill in ANTHROPIC_API_KEY and TAVILY_API_KEY
# (LangSmith keys are optional but recommended)Keys you need:
ANTHROPIC_API_KEY— Claude model access (console.anthropic.com)TAVILY_API_KEY— web search, free tier available (app.tavily.com)
uv run python -m deep_researchThen chat:
you > What are the leading approaches to long-context retrieval in 2025, and their tradeoffs?
… working (planning, searching, synthesizing)…
⏸ Approval required — write_file
args: {"file_path": "/memories/long-context-retrieval.md", ...}
[a]pprove / [e]dit / [r]eject / re[s]pond (default a) > a
agent > <synthesized, cited answer>
In-session commands: /help, /thread <id> (switch conversations), /exit.
Because state is persistent, quitting and re-running python -m deep_research
resumes the main thread exactly where you left off — including a pending
approval.
uv run --group ui streamlit run streamlit_app.py # http://localhost:8501The same agent, same threads, same .deep_research/ databases — the REPL and the
browser are two views of one conversation, so a thread you start in one continues in
the other, including one left paused at an approval. Use them one at a time: only the
checkpointer's sqlite file is in WAL mode, so simultaneous writers can collide.
It binds to localhost only (.streamlit/config.toml). The page has no authentication
and its visitor can approve file writes and spend your API keys, so putting it on a
network means putting real auth in front of it first.
What the browser adds is room the terminal doesn't have:
- A per-answer work log. The plan, each delegated sub-question and every search query, collapsed into an expander under the answer it produced.
- Readable approvals. A
write_fileshows its full contents in a scrolling code block — the terminal elides at 40 lines, and an elided review is one that gets rubber-stamped. Choosing edit prefills the real arguments so narrowing a path is an edit rather than a retype. - A memory browser. Everything under
/memories/, read straight from the Store. - Export as a markdown download — the same bytes
/exportwrites.
Nothing about what the agent is lives here: streamlit_app.py is a page script, and
deep_research/webui.py imports its display rules from cli.py rather than restating
them (StreamlitFeed subclasses ActivityFeed and overrides rendering only). Add a
tool or subagent in agent.py and all three front doors gain it.
The ui dependency group is kept out of the default install, so uv run --group ui
pulls it in on demand.
The same agent can run behind the LangGraph API server instead of the terminal, so you can drive it from LangGraph Studio or the deep-agents-ui web app:
uv run --group serve langgraph dev # serves http://127.0.0.1:2024, opens StudioThen point the UI at deployment URL http://127.0.0.1:2024, assistant id research.
deep_research/graph.py builds the same agent as the CLI (same tools, subagent,
prompt, and human-in-the-loop gate — both share agent.build_agent), but lets the
server own persistence, so its /memories/ store is separate from the CLI's
.deep_research/. The serve dependency-group stays out of the default install to
keep CI lean; uv run --group serve pulls it in on demand.
create_deep_agent(
model = ChatAnthropic("claude-opus-5") # no temperature — Opus 5 rejects it
tools = [tavily_search] # orchestrator can search directly
subagents = [researcher] # …or delegate breadth via `task`
backend = CompositeBackend(
default = StateBackend, # ephemeral, per-thread (checkpointed)
routes = {"/memories/": StoreBackend}, # durable, cross-session
)
interrupt_on = {write_file, edit_file, execute} # human approval (needs a checkpointer)
checkpointer = SqliteSaver(...) # durable thread state + interrupts
store = SqliteStore(...) # durable long-term memory
)
The CLI drives the human-in-the-loop protocol: invoke() returns with an
__interrupt__ when a gated tool is proposed; the CLI shows each pending action,
collects one decision for it, and resumes. Resuming can hit the next gated tool,
so it loops until the turn finishes.
A turn can carry more than one interrupt — the orchestrator dispatches each
task call as its own concurrent graph task and every subagent inherits
interrupt_on, so two researchers fanned out in one turn can each raise their
own. The resume value is therefore a mapping of interrupt id → that interrupt's
decisions:
Command(resume={interrupt_id: {"decisions": [...]}, ...})A flat Command(resume={"decisions": [...]}) makes LangGraph raise RuntimeError: When there are multiple pending interrupts, you must specify the interrupt id when resuming. The mapping form is also correct for the single-interrupt case, so there
is one code path.
The options it offers aren't fixed — the interrupt carries a per-tool
allowed_decisions, and the middleware rejects anything outside it, so the menu is
built from that (approve / edit / reject / respond, minus whatever the tool
forbids).
deep_research/
├── config.py # env loading, model, state paths, key checks
├── tools.py # Tavily web-search tool
├── subagents.py # the `researcher` subagent
├── agent.py # build_agent() assembles the agent; open_agent() adds disk persistence
├── graph.py # langgraph dev / Studio / web-UI entry point (server owns persistence)
├── cli.py # interactive REPL + human-in-the-loop resume loop
├── webui.py # Streamlit rendering + approval widgets (reuses cli.py's rules)
└── __main__.py # `python -m deep_research`
streamlit_app.py # `streamlit run streamlit_app.py` — the browser front door
.streamlit/ # theme (light AND dark, so the mode stays the reader's choice)
langgraph.json # registers the `research` graph for `langgraph dev`
- Add a tool — build it in
tools.py, then add it to the orchestrator'stools=[...]inagent.py(or to a subagent'stoolsinsubagents.py). - Add a subagent — return another
SubAgentdict fromsubagents.pyand include it insubagents=[...]. Give it its own tools and system prompt. - Gate more tools — add tool names to
GATED_TOOLSinagent.py. Use anInterruptOnConfigvalue (e.g.{"allowed_decisions": ["approve", "reject"]}) to restrict the available decisions per tool. - Go to production memory — swap
SqliteStoreforPostgresStore(andSqliteSaverfor a Postgres checkpointer) inagent.py. - Change what the browser UI shows —
deep_research/webui.py. A new kind of feed line means a newFeedEventkind emitted bycli.ActivityFeedplus a branch in bothActivityFeed._emit(terminal) andwebui.render_event(browser). Both are if/elif chains that draw nothing for a kind they don't know, socli.FEED_KINDSis the list they are checked against andtests/test_webui.pygoes red if either is missed.
MIT — the same license as the upstream stack this builds on
(deepagents, langchain, langgraph). Use, fork, and vendor the wiring
patterns freely.