Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build a Deep Research Agent

A deep research agent doesn't just search once and summarize; it plans sub-questions, searches each one, checks its own coverage, and loops back for gaps before writing a final report. This guide builds one with LangGraph's StateGraph that researches a one-line prompt like a competitive landscape question and produces a cited report from real search results, not the model's memory. Budget 45-60 minutes and a search API key.

What you'll learn

  • Break a research question into focused sub-questions instead of running one broad search
  • Wire a plan, search, synthesize, and reflect loop as an explicit LangGraph StateGraph
  • Force the synthesis step to cite only what the search tool actually returned, not the model's training data
  • Loop back for follow-up research when a reflection step finds a real gap, capped at a fixed number of rounds
  • Run the whole graph with a checkpointer so a long research session can resume instead of restarting
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate10 min read45-60 minPython 3.10+, langgraph, a search API key (this guide uses Tavily), Claude API keyLast updated November 2026RapidDev Engineering Team
TL;DR

A deep research agent doesn't just search once and summarize; it plans sub-questions, searches each one, checks its own coverage, and loops back for gaps before writing a final report. This guide builds one with LangGraph's StateGraph that researches a one-line prompt like a competitive landscape question and produces a cited report from real search results, not the model's memory. Budget 45-60 minutes and a search API key.

Quick facts about this guide
FactValue
ToolAny LLM or framework
DifficultyIntermediate
Time required45-60 min
CompatibilityPython 3.10+, langgraph, a search API key (this guide uses Tavily), Claude API key
Last updatedNovember 2026

What you'll build

You'll build a research agent that takes one line, something like 'competitive landscape of vertical SaaS AI agents in field services,' and comes back with a short, sourced report instead of a single search result pasted into a paragraph. The difference between this and a one-shot 'search and summarize' call is the loop: the agent plans sub-questions first, searches each one, checks whether its own findings actually answer the original question, and goes back for more if they don't.

LangGraph models that loop as an explicit state graph instead of hiding it inside a single agent's internal reasoning. A plan node writes sub-questions into shared state, a search node fills in findings for each one, a synthesize node writes the report from those findings only, and a reflect node decides whether to loop back to planning or stop. Because the state and the routing are explicit, you can inspect exactly what the agent found at each step and cap the loop at a fixed number of rounds instead of trusting the model to know when to stop on its own.

This pattern works with any search tool. This guide uses a direct call to a search API inside the search node rather than Anthropic's hosted web search tool, so you can swap in whichever search provider your team already pays for without changing the graph's shape.

Prerequisites

  • Python 3.10+ and a terminal
  • pip install langgraph langchain langchain-anthropic httpx
  • An Anthropic API key (ANTHROPIC_API_KEY) with available credits
  • A search API key (this guide uses Tavily; swap the search_web function for any provider you already use)

Step-by-step guide

1

Install dependencies and set your keys

Install LangGraph alongside LangChain's Anthropic integration, which gives you init_chat_model so you can reference a model by a short provider:model string instead of importing a provider-specific client directly. Export both your Anthropic key and your search provider's key; the research loop needs both on every run.

typescript
1pip install langgraph langchain langchain-anthropic httpx
2export ANTHROPIC_API_KEY="sk-ant-..."
3export TAVILY_API_KEY="tvly-..."

Expected result: pip install finishes cleanly and python -c "import langgraph" runs with no errors.

2

Define the graph's shared state

A TypedDict describes everything that flows between nodes: the original query, the list of sub-questions still to research, the findings collected so far, the current iteration count, and the final report once it's written. Every node reads from and writes back into this same shared state, which is what lets you inspect progress at any point in the loop.

typescript
1from typing import TypedDict
2
3class ResearchState(TypedDict):
4 query: str
5 sub_questions: list[str]
6 findings: list[dict]
7 report: str
8 iteration: int

Expected result: A state schema with no logic yet, just the shape every node in the graph will read and write.

3

Write the plan node

Ask the model to break the original query into 3-5 focused sub-questions, each narrow enough to answer with a single search. Splitting here is what separates this from a single broad search call: a sub-question like 'who are the top 3 funded competitors' returns far more useful search results than searching the whole original query verbatim.

typescript
1from langchain.chat_models import init_chat_model
2
3llm = init_chat_model("anthropic:claude-sonnet-5-5")
4
5def plan_node(state: ResearchState) -> dict:
6 prompt = (
7 f"Break this research question into 3-5 focused sub-questions, "
8 f"one per line, no numbering: {state['query']}"
9 )
10 lines = llm.invoke(prompt).content.strip().split("\n")
11 return {"sub_questions": [l.strip("- ").strip() for l in lines if l.strip()]}

Expected result: state['sub_questions'] populated with a handful of narrow, searchable questions derived from the original prompt.

4

Write the search node

For each sub-question that doesn't already have findings, call a search function and store the top results with their titles, URLs, and snippets. Keeping findings keyed by sub-question is what makes the reflect step able to tell which sub-questions still have nothing, rather than just a pile of undifferentiated search results.

typescript
1import httpx, os
2
3def search_web(query: str, max_results: int = 4) -> list[dict]:
4 resp = httpx.post("https://api.tavily.com/search", json={
5 "api_key": os.environ["TAVILY_API_KEY"], "query": query,
6 "max_results": max_results,
7 }, timeout=20)
8 return [{"title": r["title"], "url": r["url"], "snippet": r["content"]}
9 for r in resp.json().get("results", [])]
10
11def search_node(state: ResearchState) -> dict:
12 researched = {f["sub_question"] for f in state["findings"]}
13 new_findings = list(state["findings"])
14 for q in state["sub_questions"]:
15 if q in researched:
16 continue
17 for r in search_web(q):
18 new_findings.append({"sub_question": q, **r})
19 return {"findings": new_findings}

Expected result: state['findings'] grows by several entries per sub-question, each carrying a real title, URL, and snippet.

5

Write the synthesize node

Feed every finding, grouped by sub-question, into the model and instruct it to write the report using only what's in those findings, with a numbered source list at the end. The explicit instruction matters: without it, the model will quietly blend in things it already knew from training, which is exactly what makes a research report untrustworthy.

typescript
1def synthesize_node(state: ResearchState) -> dict:
2 sources = "\n\n".join(
3 f"[{i+1}] {f['title']} ({f['url']})\n{f['snippet']}"
4 for i, f in enumerate(state["findings"])
5 )
6 prompt = (
7 f"Write a short report answering: {state['query']}\n\n"
8 f"Use ONLY the sources below, cite them inline as [n], and list them "
9 f"at the end. If the sources don't cover something, say so explicitly.\n\n{sources}"
10 )
11 return {"report": llm.invoke(prompt).content}

Expected result: A multi-paragraph report with inline [n] citations that trace back to a real URL in the findings list.

6

Write the reflect node and wire the loop

Ask the model whether the report actually answers the original query or is missing something a reasonable reader would expect, and cap how many extra rounds it can request. Without a hard cap, a model that's never fully satisfied with its own coverage will loop until you run out of budget; two or three extra rounds is enough for nearly any question.

typescript
1MAX_ITERATIONS = 2
2
3def reflect_node(state: ResearchState) -> dict:
4 return {"iteration": state["iteration"] + 1}
5
6def route_after_reflect(state: ResearchState) -> str:
7 if state["iteration"] >= MAX_ITERATIONS:
8 return "done"
9 check = llm.invoke(
10 f"Does this report fully answer '{state['query']}'? Reply only YES or NO.\n\n{state['report']}"
11 ).content
12 return "done" if "YES" in check.upper() else "replan"

Expected result: A routing function that sends the graph back to planning on a real gap, and stops once the cap is reached or coverage looks complete.

7

Assemble and run the graph

Wire the four nodes together with add_node and add_edge, route the conditional edge from reflect back to plan or on to END, and compile with a checkpointer so a thread_id can resume a research session instead of starting over if it's interrupted. Invoke it with the original query and a thread_id, then print the final report.

typescript
1from langgraph.graph import StateGraph, START, END
2from langgraph.checkpoint.memory import InMemorySaver
3
4graph = StateGraph(ResearchState)
5graph.add_node("plan", plan_node)
6graph.add_node("search", search_node)
7graph.add_node("synthesize", synthesize_node)
8graph.add_node("reflect", reflect_node)
9graph.add_edge(START, "plan")
10graph.add_edge("plan", "search")
11graph.add_edge("search", "synthesize")
12graph.add_edge("synthesize", "reflect")
13graph.add_conditional_edges("reflect", route_after_reflect, {"replan": "plan", "done": END})
14app = graph.compile(checkpointer=InMemorySaver())
15
16result = app.invoke(
17 {"query": "...", "sub_questions": [], "findings": [], "report": "", "iteration": 0},
18 config={"configurable": {"thread_id": "research-1"}},
19)
20print(result["report"])

Expected result: A printed report with inline citations, after the graph loops through plan, search, and synthesize at least once, possibly twice if reflect found a gap.

Complete working example

deep_research_agent.py
1"""Deep research agent: plans sub-questions, searches, synthesizes a cited
2report, and loops back on real gaps. Run: python deep_research_agent.py
3pip install langgraph langchain langchain-anthropic httpx
4"""
5import os
6from typing import TypedDict
7
8import httpx
9from langchain.chat_models import init_chat_model
10from langgraph.checkpoint.memory import InMemorySaver
11from langgraph.graph import END, START, StateGraph
12
13MAX_ITERATIONS = 2
14llm = init_chat_model("anthropic:claude-sonnet-5-5")
15
16
17class ResearchState(TypedDict):
18 query: str
19 sub_questions: list[str]
20 findings: list[dict]
21 report: str
22 iteration: int
23
24
25def search_web(query: str, max_results: int = 4) -> list[dict]:
26 """Swap this for any search API your team already pays for."""
27 resp = httpx.post(
28 "https://api.tavily.com/search",
29 json={"api_key": os.environ["TAVILY_API_KEY"], "query": query, "max_results": max_results},
30 timeout=20,
31 )
32 return [
33 {"title": r["title"], "url": r["url"], "snippet": r["content"]}
34 for r in resp.json().get("results", [])
35 ]
36
37
38def plan_node(state: ResearchState) -> dict:
39 prompt = (
40 f"Break this research question into 3-5 focused sub-questions, "
41 f"one per line, no numbering: {state['query']}"
42 )
43 lines = llm.invoke(prompt).content.strip().split("\n")
44 return {"sub_questions": [l.strip("- ").strip() for l in lines if l.strip()]}
45
46
47def search_node(state: ResearchState) -> dict:
48 researched = {f["sub_question"] for f in state["findings"]}
49 new_findings = list(state["findings"])
50 for q in state["sub_questions"]:
51 if q in researched:
52 continue
53 for r in search_web(q):
54 new_findings.append({"sub_question": q, **r})
55 return {"findings": new_findings}
56
57
58def synthesize_node(state: ResearchState) -> dict:
59 sources = "\n\n".join(
60 f"[{i + 1}] {f['title']} ({f['url']})\n{f['snippet']}"
61 for i, f in enumerate(state["findings"])
62 )
63 prompt = (
64 f"Write a short report answering: {state['query']}\n\n"
65 f"Use ONLY the sources below, cite them inline as [n], and list them "
66 f"at the end. If the sources don't cover something, say so explicitly.\n\n{sources}"
67 )
68 return {"report": llm.invoke(prompt).content}
69
70
71def reflect_node(state: ResearchState) -> dict:
72 return {"iteration": state["iteration"] + 1}
73
74
75def route_after_reflect(state: ResearchState) -> str:
76 if state["iteration"] >= MAX_ITERATIONS:
77 return "done"
78 check = llm.invoke(
79 f"Does this report fully answer '{state['query']}'? Reply only YES or NO.\n\n{state['report']}"
80 ).content
81 return "done" if "YES" in check.upper() else "replan"
82
83
84def build_graph():
85 graph = StateGraph(ResearchState)
86 graph.add_node("plan", plan_node)
87 graph.add_node("search", search_node)
88 graph.add_node("synthesize", synthesize_node)
89 graph.add_node("reflect", reflect_node)
90 graph.add_edge(START, "plan")
91 graph.add_edge("plan", "search")
92 graph.add_edge("search", "synthesize")
93 graph.add_edge("synthesize", "reflect")
94 graph.add_conditional_edges("reflect", route_after_reflect, {"replan": "plan", "done": END})
95 return graph.compile(checkpointer=InMemorySaver())
96
97
98def main():
99 app = build_graph()
100 result = app.invoke(
101 {
102 "query": "Competitive landscape of vertical SaaS AI agents in field services",
103 "sub_questions": [],
104 "findings": [],
105 "report": "",
106 "iteration": 0,
107 },
108 config={"configurable": {"thread_id": "research-1"}},
109 )
110 print(result["report"])
111
112
113if __name__ == "__main__":
114 main()
115

Common mistakes

Why it's a problem: Sending the original broad query straight to a search API instead of breaking it into sub-questions

How to avoid: Plan 3-5 narrow sub-questions first. A search engine returns far more useful results for 'who are the top funded competitors in X' than for the full original research question verbatim.

Why it's a problem: Letting the synthesis step write from the model's training data instead of only the search findings

How to avoid: State explicitly that the report must use only the provided sources and must say so when something isn't covered. Without that instruction, the model quietly blends in outdated or ungrounded claims.

Why it's a problem: No cap on the reflect-and-replan loop

How to avoid: A model asked 'is this good enough' will rarely say yes on the first try. Cap iterations at 2-3 rounds regardless of what reflection decides.

Why it's a problem: Re-searching the same sub-question on every loop instead of only the ones still missing findings

How to avoid: Key findings by sub-question and skip ones that already have results, the way the search_node in this guide does, so a replan round only fills real gaps.

Why it's a problem: Treating a single search result as sufficient for a sub-question

How to avoid: Pull several results per sub-question. One source can be wrong, outdated, or off-topic; multiple results let the synthesis step cross-check.

Best practices

  • Keep sub-questions narrow and specific; a vague sub-question produces vague, low-signal search results.
  • Cite inline with a numbered source list so every claim in the report traces back to a real URL.
  • Cap replan loops at a fixed number of rounds; don't let the model decide when it's 'done enough.'
  • Key findings by the sub-question that produced them, so you can tell which questions still have gaps.
  • Use a checkpointer with a stable thread_id so a long research session can resume instead of restarting from scratch.
  • Log every sub-question and the search results it returned, not just the final report, so you can audit how a conclusion was reached.

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have this LangGraph deep research agent (below) with plan, search, synthesize, and reflect nodes, capped at 2 replan rounds. Add a node that deduplicates findings with the same URL before synthesis, and have reflect also flag when two sources directly contradict each other. [paste complete_code]

Build AI Agents Prompt

In Claude Code, open deep_research_agent.py and ask: 'Add a human-in-the-loop interrupt after the plan node so I can edit the sub-questions list before the search node runs.'

Frequently asked questions

How is a deep research agent different from just asking an LLM a question directly?

Asking directly gets you an answer from the model's training data, which can be outdated or simply wrong on specifics. A deep research agent plans sub-questions, runs real searches, and writes its report only from what those searches returned, with citations you can check.

Why use LangGraph instead of a single agent loop for this?

The plan, search, synthesize, and reflect steps are genuinely different operations with different inputs and outputs. Modeling them as explicit graph nodes lets you inspect state between steps, cap the loop deterministically, and swap one node (like the search function) without touching the others.

How many rounds of research should I allow before stopping?

Two or three replan rounds covers nearly any question. Past that, additional rounds usually chase diminishing returns rather than closing a real gap, and you're better off narrowing the original query instead.

Can I use a different search API than Tavily?

Yes. The search_web function is a single, isolated piece; swap its implementation for Exa, Brave Search, Bing, or Anthropic's hosted web search tool without changing anything else in the graph.

How much does one research run cost?

Budget for one plan call, one search call per sub-question, one synthesis call, and one reflect call per round, each a few thousand tokens on a mid-tier model, plus per-search API costs. A typical 3-5 sub-question run on one round usually lands under a few cents in model cost.

Can someone build a deep research agent for my specific domain?

Yes. RapidDev builds research agents scoped to a specific industry or data source, including the citation and gap-checking logic in this guide. A free 30-minute consultation is the fastest way to scope your version.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.