← All posts

How to Add a Live Web Search and Fetch Tool to a LangGraph Agent

LangGraph is great at orchestrating the loop of an agent: nodes, edges, state that flows between them. What it does not give you is the actual web. Out of the box your agent can reason all day about a topic, but it cannot go read a page that changed this morning.

So let's fix that. This is the wiring, not the philosophy: two tools, one graph, and a grounding prompt that keeps the model honest.

The two tools you actually need

Almost every research agent needs exactly two capabilities. One to find pages (search) and one to read a specific page (fetch). Split them, because the model uses them at different moments. It searches when it does not know where the answer lives, and it fetches when it already has a URL and wants the full text.

I'll use link.sc for both, because it returns clean markdown instead of raw HTML and handles the JavaScript rendering and proxy headaches server-side. You could swap in any search API and HTTP client. The graph wiring is identical either way.

Define the tools with the standard @tool decorator so LangGraph can read their schemas from the type hints and docstrings:

import os
import requests
from langchain_core.tools import tool

LINKSC = {"x-api-key": os.environ["LINKSC_API_KEY"]}

@tool
def web_search(query: str) -> str:
    """Search the web for current information.
    Returns ranked results with page content as markdown."""
    r = requests.post(
        "https://api.link.sc/v1/search",
        headers=LINKSC,
        json={"q": query, "format": "markdown", "num_results": 5},
    )
    return r.text

@tool
def fetch_url(url: str) -> str:
    """Fetch a single URL and return its main content as clean markdown."""
    r = requests.post(
        "https://api.link.sc/v1/fetch",
        headers=LINKSC,
        json={"url": url, "format": "markdown"},
    )
    return r.json()["content"]

The docstrings matter more than they look. The model reads them to decide when to call each tool, so write them the way you would write an instruction to a junior engineer: say what the tool does and what it returns.

The fast path: prebuilt ReAct agent

If you just want a working agent, LangGraph ships a prebuilt that builds the whole reason-act-observe loop for you.

from langchain_anthropic import ChatAnthropic
from langgraph.prebuilt import create_react_agent

llm = ChatAnthropic(model="claude-opus-4-8")
agent = create_react_agent(llm, tools=[web_search, fetch_url])

result = agent.invoke(
    {"messages": [("user", "What changed in the latest LangGraph release?")]}
)
print(result["messages"][-1].content)

That is a complete agent. The model searches, reads the returned markdown, optionally fetches the changelog page directly, and answers from what it read. For a lot of use cases you can stop here.

But create_react_agent is a black box, and the whole reason people reach for LangGraph is that they want to see and control the graph. So let's build the same thing by hand.

The explicit graph: a ToolNode and a conditional edge

The pattern is two nodes and a loop. An agent node calls the model. A tools node runs whatever tools the model asked for. A conditional edge decides whether to keep looping or finish.

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition

class State(TypedDict):
    messages: Annotated[list, add_messages]

tools = [web_search, fetch_url]
llm_with_tools = llm.bind_tools(tools)

def call_model(state: State):
    return {"messages": [llm_with_tools.invoke(state["messages"])]}

graph = StateGraph(State)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(tools))

graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")

app = graph.compile()

Three things are doing the real work here.

add_messages is the reducer on the state. When a node returns new messages, they get appended to the running list instead of replacing it, so the conversation and every tool result accumulate as the graph runs.

ToolNode is the prebuilt that executes tool calls. It reads the tool_calls off the last message, runs the matching functions, and writes back ToolMessage objects. You do not hand-write the dispatch logic.

tools_condition is the router. It inspects the model's last message: if there are tool calls, it routes to the tools node; if not, it routes to the end. That single line is what turns a straight pipeline into a loop that runs until the model is done gathering data.

Run it the same way:

out = app.invoke(
    {"messages": [("user", "Compare the pricing pages of Vercel and Netlify.")]}
)
print(out["messages"][-1].content)

The model will likely search for each pricing page, fetch both URLs, and synthesize a comparison from the markdown it pulled. You can watch every hop by streaming instead of invoking:

for step in app.stream({"messages": [("user", "...")]}, stream_mode="values"):
    step["messages"][-1].pretty_print()

Grounding: the step people skip

Giving the agent tools does not stop it from making things up. The instruction that does is telling it to answer only from what the tools returned. Add a system message to the front of the state:

from langchain_core.messages import SystemMessage

SYSTEM = SystemMessage(content=(
    "Answer using only the content returned by web_search and fetch_url. "
    "If the tools did not return the answer, say you could not find it. "
    "Cite the source URL for every factual claim."
))

app.invoke({"messages": [SYSTEM, ("user", question)]})

This flips the model's job from recall to reading comprehension over text you handed it, which is the thing LLMs are genuinely reliable at. The citation requirement is a cheap correctness check too, because a wrong claim now comes with a link you can click and verify. If you want more background on why this matters, I wrote about it in give your AI agent internet access and real-time web search for LLMs.

A few things that will bite you

Return markdown, not HTML. Raw HTML can be ten times the tokens for the same content, and your context window and your bill both feel it. The "format": "markdown" in the fetch call is doing quiet work.

Cap num_results. The model does not need twenty search hits. Five gives it enough to pick a good URL to fetch, and it keeps the tool output small enough that the model can actually read it.

Handle the fetch that comes back empty or errors. A dead URL should return a short "could not fetch" string, not a stack trace that ends up in the model's context and confuses it. Wrap the request in a try/except and return a plain message.

Set a recursion limit. app.invoke(inputs, {"recursion_limit": 10}) stops a confused agent from looping forever on tool calls. Without it, a model that keeps deciding to search one more time can run up a surprising number of calls.

Where this fits

This same two-tool graph is the backbone of most agentic research setups. Once the search-and-fetch loop works, the interesting part is what you build around it: a planner node in front, a verification node after, memory between runs. LangGraph is good at exactly that kind of structure, and now the graph can see the live web instead of guessing at it. If you want the conceptual tour of that pattern, see what is agentic search.

On link.sc a search or a fetch is roughly one credit, and the free tier includes 500 credits a month, which is plenty to build and test the whole graph before you pay anything.


Wire live web access into your LangGraph agent today: grab a free API key at link.sc/register.