LangChain · Python and TypeScript
Build a research agent with LangGraph
Build a research agent on LangGraph, wiring unlob search and context assembly as tools on a prebuilt ReAct graph with checkpointed state.
What you are building
Your process
create_react_agent
Tool-calling loop and message state
InMemorySaver
Checkpointed per thread_id
Tools you defined
web_search
GET /search
assemble_context
GET /assemble_context
Services
Anthropic API
claude-opus-5
unlob
Your own index
Install
pip install langgraph langchain-anthropic httpxAlready have LangGraph wired up? TheLangGraph integration pagehas the connection config on its own — this page assumes you have it and gets on with building.
Step by step
Wrap the two calls as tools
LangGraph takes plain callables decorated with @tool. Keep the docstrings precise — they are the only thing the model sees when deciding which tool to reach for, and a vague one is the most common cause of an agent picking wrong.
tools.pypython import os, httpx from langchain_core.tools import tool API = "https://api.unlob.com" HEADERS = {"x-api-key": os.environ["UNLOB_API_KEY"]} @tool def web_search(query: str, min_sources: int = 2) -> list[dict]: """Search the web. Returns metadata only — url, title, snippet, score and independent_sources. Use min_sources=3 for factual claims that need corroboration. Does NOT return page bodies.""" r = httpx.get(f"{API}/search", headers=HEADERS, params={ "q": query, "min_independent_sources": min_sources, "fields[]": ["url", "title", "snippet", "independent_sources"], "limit": 8, }, timeout=15) r.raise_for_status() return r.json()["results"] @tool def assemble_context(query: str, budget: int = 3000) -> dict: """Build a corroborated, deduplicated context pack for a question, packed to a token budget. Prefer this over several web_search calls when you need to read source material rather than just find it.""" r = httpx.get(f"{API}/assemble_context", headers=HEADERS, params={ "query": query, "budget": budget, "min_independent_sources": 2, }, timeout=30) r.raise_for_status() return r.json()Build the graph
create_react_agent gives you the tool-calling loop, message state and streaming without hand-writing the graph. Reach for a raw StateGraph only when you need branches this does not express.
agent.pypython from langchain_anthropic import ChatAnthropic from langgraph.prebuilt import create_react_agent from langgraph.checkpoint.memory import InMemorySaver from tools import web_search, assemble_context model = ChatAnthropic( model="claude-opus-5", max_tokens=4096, thinking={"type": "adaptive"}, # not budget_tokens — that 400s ) agent = create_react_agent( model, tools=[web_search, assemble_context], prompt=( "You are a research assistant. Search before you answer. " "Prefer claims carried by several independent sources, and say so " "when a claim rests on only one. Always cite URLs." ), checkpointer=InMemorySaver(), )Run it with a thread id
The checkpointer keys on thread_id. Same id, same conversation; new id, fresh state. Swap InMemorySaver for the Postgres or SQLite saver and the same code survives a restart.
run.pypython config = {"configurable": {"thread_id": "research-1"}} for chunk in agent.stream( {"messages": [("user", "What changed in the EU AI Act GPAI rules in 2026?")]}, config, stream_mode="values", ): chunk["messages"][-1].pretty_print()
What happens when it runs
The whole thing
Complete and runnable. Set the two environment variables and it works.
"""A research agent on LangGraph, backed by the unlob web index.
pip install langgraph langchain-anthropic httpx
export UNLOB_API_KEY=ulb_...
export ANTHROPIC_API_KEY=sk-ant-...
"""
import os
import httpx
from langchain_core.tools import tool
from langchain_anthropic import ChatAnthropic
from langgraph.prebuilt import create_react_agent
from langgraph.checkpoint.memory import InMemorySaver
API = "https://api.unlob.com"
HEADERS = {"x-api-key": os.environ["UNLOB_API_KEY"]}
@tool
def web_search(query: str, min_sources: int = 2) -> list[dict]:
"""Search the web. Returns metadata only — url, title, snippet, score and
independent_sources. Use min_sources=3 for factual claims that need
corroboration. Does NOT return page bodies."""
r = httpx.get(f"{API}/search", headers=HEADERS, params={
"q": query,
"min_independent_sources": min_sources,
"fields[]": ["url", "title", "snippet", "independent_sources"],
"limit": 8,
}, timeout=15)
r.raise_for_status()
return r.json()["results"]
@tool
def assemble_context(query: str, budget: int = 3000) -> dict:
"""Build a corroborated, deduplicated context pack for a question, packed to
a token budget. Prefer this over several web_search calls when you need to
read source material rather than just find it."""
r = httpx.get(f"{API}/assemble_context", headers=HEADERS, params={
"query": query, "budget": budget, "min_independent_sources": 2,
}, timeout=30)
r.raise_for_status()
return r.json()
agent = create_react_agent(
ChatAnthropic(
model="claude-opus-5",
max_tokens=4096,
thinking={"type": "adaptive"},
),
tools=[web_search, assemble_context],
prompt=(
"You are a research assistant. Search before you answer. "
"Prefer claims carried by several independent sources, and say so "
"when a claim rests on only one. Always cite URLs."
),
checkpointer=InMemorySaver(),
)
if __name__ == "__main__":
config = {"configurable": {"thread_id": "research-1"}}
question = "What changed in the EU AI Act GPAI rules in 2026?"
for chunk in agent.stream(
{"messages": [("user", question)]}, config, stream_mode="values"
):
chunk["messages"][-1].pretty_print()What will go wrong
The failures that cost an afternoon rather than a minute, because they produce something that looks like it is working.
Tool docstrings are prompt, not documentation. The model chooses between web_search and assemble_context on those sentences alone — if it keeps calling search five times where one context pack would do, the fix is in the docstring, not the graph.
Set `thinking={"type": "adaptive"}`, not `budget_tokens`. The older form is rejected outright on current models rather than ignored.
InMemorySaver loses everything on restart. It is right for the tutorial and wrong for anything you deploy — swap in the SQLite or Postgres checkpointer before you ship.
Return the parsed list from web_search rather than a JSON string. LangGraph serialises tool results for you, and double-encoding wastes tokens and confuses the model.
Where this agent can go next
Every result the agent receives is a node in the coverage graph. These are the tools you can add to it without leaving LangGraph — each is one more entry in the same tools list.
Frequently asked questions
Do I need LangChain as well as LangGraph?
You need `langchain-core` for the @tool decorator and a model provider package such as `langchain-anthropic`. You do not need the full `langchain` package — LangGraph is usable on its own and most production deployments keep the dependency surface small.
Should I use create_react_agent or build a StateGraph?
Start with create_react_agent. It is the same loop you would hand-write, and you can drop to a raw StateGraph the moment you need a branch it cannot express — routing to different tool sets, a human approval step, or a parallel fan-out.
How do I stop the agent burning tokens on search results?
The `fields[]` parameter in the tool above is doing that work: it returns url, title, snippet and independent_sources and nothing else. Search never returns page bodies, so full text only arrives when the agent explicitly asks for a document.
You need a key to run this
10,000 requests a month on the free tier, no card. Enough to build the agent and put a real evaluation set through it.