Build a LangGraph Research Agent That Searches, Reads, and Cites
A stateful LangGraph loop that plans queries, calls Serply's search API, fetches full pages through the scraper, and stops when it has enough to answer.


- The shape of the graph
- State
- The Serply calls
- The nodes
- The conditional edge
- Wiring it up
- The answer node
- What to tune
A single search call is not research. Research is a loop: ask something, look at what comes back, notice what’s missing, ask a better question. Tool-calling agents built on a plain while loop tend to do this badly — they either stop after the first result set or spiral for twenty calls with no sense of when they’re done.
LangGraph is a good fit here precisely because it makes the loop explicit. You define nodes, you define edges, and the “should I keep going?” decision becomes a conditional edge you can actually reason about instead of a prompt you hope the model respects.
The shape of the graph
Four nodes:
- plan — turn the user’s question into one or more concrete search queries
- search — hit Serply’s Google Search endpoint for each query
- read — pull full page text for the most promising results
- answer — write the final response with citations
The interesting edge is between read and plan: if the gathered material doesn’t cover the question, we go back and plan again with what we’ve learned.
State
from typing import Annotated, TypedDict
import operator
class ResearchState(TypedDict):
question: str
queries: list[str]
results: Annotated[list[dict], operator.add]
documents: Annotated[list[dict], operator.add]
iterations: int
Using operator.add on results and documents means nodes append rather than overwrite, which is what you want when the loop runs more than once.
The Serply calls
Two functions, both thin. Search first:
import os
import requests
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
HEADERS = {"X-Api-Key": API_KEY}
def serply_search(query: str, num: int = 10) -> list[dict]:
"""Google Search via Serply. Returns the organic results array."""
q = quote_plus(query)
url = f"https://api.serply.io/v1/search/q={q}&num={num}"
resp = requests.get(url, headers={**HEADERS, "X-Proxy-Location": "US"}, timeout=30)
resp.raise_for_status()
return resp.json().get("results", [])
Note the URL shape — Serply embeds the Google-style query string in the path segment, so it’s /v1/search/q=..., not /v1/search?q=.... Each result comes back with title, link, description, position, and a metadata object that usually carries display_url.
Then the reader, which is where most agents fall down. A search snippet is two lines; answering a real question usually needs the page:
def serply_read(url: str) -> str:
"""Fetch a URL as markdown through Serply's scraper."""
resp = requests.post(
"https://api.serply.io/v1/request",
headers={**HEADERS, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
return resp.text
The markdown variant returns the converted text directly in the body — not JSON — so read .text, not .json(). If you ask for "response_type": "full" instead, you get a JSON object with the raw HTML under a data key.
The nodes
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
llm = ChatOpenAI(model="gpt-4o", temperature=0)
def plan_node(state: ResearchState) -> dict:
known = "\n".join(d["url"] for d in state.get("documents", []))
prompt = (
f"Question: {state['question']}\n\n"
f"Already read:\n{known or '(nothing yet)'}\n\n"
"Write up to 3 Google queries that would fill the remaining gaps. "
"One per line, no numbering."
)
out = llm.invoke([SystemMessage(content="You plan web research."),
HumanMessage(content=prompt)])
queries = [line.strip() for line in out.content.splitlines() if line.strip()][:3]
return {"queries": queries, "iterations": state.get("iterations", 0) + 1}
def search_node(state: ResearchState) -> dict:
hits = []
for q in state["queries"]:
hits.extend(serply_search(q, num=10))
return {"results": hits}
def read_node(state: ResearchState) -> dict:
seen = {d["url"] for d in state.get("documents", [])}
fresh = [r for r in state["results"] if r.get("link") and r["link"] not in seen]
docs = []
for r in fresh[:4]:
try:
docs.append({
"url": r["link"],
"title": r.get("title", ""),
"text": serply_read(r["link"])[:12000],
})
except requests.HTTPError:
continue
return {"documents": docs}
Capping at four pages per iteration keeps latency and cost predictable. Truncating to 12k characters keeps the context window from filling up with boilerplate.
The conditional edge
def should_continue(state: ResearchState) -> str:
if state["iterations"] >= 3:
return "answer"
if len(state["documents"]) >= 6:
return "answer"
return "plan"
Hard caps, not a model judgment call. You can add an LLM-based sufficiency check on top, but always keep a numeric ceiling underneath it — that’s what stops a bad day from turning into a hundred-dollar API bill.
Wiring it up
from langgraph.graph import StateGraph, START, END
builder = StateGraph(ResearchState)
builder.add_node("plan", plan_node)
builder.add_node("search", search_node)
builder.add_node("read", read_node)
builder.add_node("answer", answer_node)
builder.add_edge(START, "plan")
builder.add_edge("plan", "search")
builder.add_edge("search", "read")
builder.add_conditional_edges("read", should_continue,
{"plan": "plan", "answer": "answer"})
builder.add_edge("answer", END)
graph = builder.compile()
result = graph.invoke({
"question": "What changed in EU AI Act enforcement timelines this year?",
"iterations": 0,
})
print(result["answer"])
The answer node
def answer_node(state: ResearchState) -> dict:
sources = "\n\n".join(
f"[{i+1}] {d['title']} — {d['url']}\n{d['text'][:3000]}"
for i, d in enumerate(state["documents"])
)
prompt = (
f"Question: {state['question']}\n\nSources:\n{sources}\n\n"
"Answer using only these sources. Cite with [n]. "
"If the sources don't answer it, say so."
)
out = llm.invoke([SystemMessage(content="You write cited research answers."),
HumanMessage(content=prompt)])
return {"answer": out.content}
That last instruction matters more than it looks. Without it the model will happily fall back on training data and present it in the same voice as the cited material, which is exactly the failure mode you built this graph to avoid.
What to tune
The num=10 on search is doing real work — dropping to 5 roughly halves your read volume downstream, since the reader only ever looks at the top few links. If your questions are geographically specific, set X-Proxy-Location to match (the endpoint accepts US, GB, DE, JP, AU, and about ten others); a query about local regulations returns visibly different results from a US exit node than a German one.
Watch for 429 responses under parallel load. The response carries x-ratelimit-requests-remaining, so you can back off before you hit the wall rather than after.