Skip to content
unlob

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

Your side of the API. unlob is one HTTPS service among the others — nothing here is specific to how our index works, so this agent survives a change of framework.

Install

Dependenciesbash
pip install langgraph langchain-anthropic httpx

Already 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

  1. 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()
  2. 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(),
    )
  3. 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

YouLangGraphClaudeunlobinvoke(question)messages + tool schemastool_use: web_searchGET /searchmetadata hitstool_resultanswer with citationsfinal state
One turn of the loop. Search returns metadata only, so the agent spends a few hundred tokens deciding rather than tens of thousands reading.

The whole thing

Complete and runnable. Set the two environment variables and it works.

research_agent.py — completepython
"""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.

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.

relatedNear-duplicatescorroborateIndependent sourcesauthoritiesAuthoritative hostsdossierThe entity briefpathA second documentassemble_contextA packed context setA search resultwhat your agent has
The two marked in blue are the tools this tutorial wires up. The other four are the same shape.

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.