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.
| Fact | Value |
|---|---|
| Tool | Any LLM or framework |
| Difficulty | Intermediate |
| Time required | 45-60 min |
| Compatibility | Python 3.10+, langgraph, a search API key (this guide uses Tavily), Claude API key |
| Last updated | November 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
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.
1pip install langgraph langchain langchain-anthropic httpx2export 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.
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.
1from typing import TypedDict23class ResearchState(TypedDict):4 query: str5 sub_questions: list[str]6 findings: list[dict]7 report: str8 iteration: intExpected result: A state schema with no logic yet, just the shape every node in the graph will read and write.
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.
1from langchain.chat_models import init_chat_model23llm = init_chat_model("anthropic:claude-sonnet-5-5")45def 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.
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.
1import httpx, os23def 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", [])]1011def 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 continue17 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.
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.
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.
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.
1MAX_ITERATIONS = 223def reflect_node(state: ResearchState) -> dict:4 return {"iteration": state["iteration"] + 1}56def 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 ).content12 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.
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.
1from langgraph.graph import StateGraph, START, END2from langgraph.checkpoint.memory import InMemorySaver34graph = 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())1516result = 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
1"""Deep research agent: plans sub-questions, searches, synthesizes a cited2report, and loops back on real gaps. Run: python deep_research_agent.py3pip install langgraph langchain langchain-anthropic httpx4"""5import os6from typing import TypedDict78import httpx9from langchain.chat_models import init_chat_model10from langgraph.checkpoint.memory import InMemorySaver11from langgraph.graph import END, START, StateGraph1213MAX_ITERATIONS = 214llm = init_chat_model("anthropic:claude-sonnet-5-5")151617class ResearchState(TypedDict):18 query: str19 sub_questions: list[str]20 findings: list[dict]21 report: str22 iteration: int232425def 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 ]363738def 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()]}454647def 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 continue53 for r in search_web(q):54 new_findings.append({"sub_question": q, **r})55 return {"findings": new_findings}565758def 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}697071def reflect_node(state: ResearchState) -> dict:72 return {"iteration": state["iteration"] + 1}737475def 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 ).content81 return "done" if "YES" in check.upper() else "replan"828384def 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())969798def 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"])111112113if __name__ == "__main__":114 main()115Common 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.
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]
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.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation