A LangChain agent without a search tool is a closed book. It reasons beautifully over whatever is in its context, but ask it about a library released last month or a price that changed yesterday and it either hedges or invents something plausible. The fix takes about twenty lines: define a search tool, hand it to the agent, and let the model decide when to call it.
This post covers the search integrations LangChain ships with, where they fall short, and how to build a custom search tool that returns content an LLM can actually answer from.
The built-in options
LangChain has community integrations for most search providers. The usual suspects:
| Tool | Cost | What you get back |
|---|---|---|
DuckDuckGoSearchRun |
Free, no API key | Text snippets, aggressive rate limits |
| Tavily | Paid key, free tier | Snippets tuned for LLMs, optional raw content |
| SerpAPI / GoogleSerper | Paid key | Structured Google SERP data, snippets |
| Custom tool (this post) | Your choice of backend | Whatever you return from the function |
For a weekend project, DuckDuckGoSearchRun is genuinely fine. Two imports and you have a working search agent. The catch shows up under load: it scrapes DuckDuckGo without a key, so it starts throwing rate limit errors the moment you run more than a handful of queries.
The paid options are more reliable, but they share a structural problem worth understanding before you pick one.
The snippet problem
Almost every search API returns snippets: a title, a URL, and one or two sentences pulled from the page. That format was designed for humans scanning a results page, not for a model trying to answer a question.
Feed snippets to your agent and it answers from blurbs. For "capital of France" that works. For "what breaking changes are in the 2.0 release" the answer is four paragraphs down on the actual page, and the snippet just says the release exists. The model then does one of two things: admits it cannot tell, or confidently fills the gap from stale training data. The second failure mode is the dangerous one, because the answer arrives with a citation attached and looks grounded.
The reliable pattern is search, then read. Search finds candidate URLs, a fetch step pulls the full content of the best ones, and the model answers from what it actually read. I walked through the reasoning in more detail in this post on real-time web search for LLMs; here we will wire that pattern directly into LangChain.
Building a custom search tool
LangChain's @tool decorator turns any function into a tool. The docstring matters more than it looks: it is the description the model reads when deciding whether to call the tool, so write it for the model, not for your linter.
Here is a search tool backed by link.sc, which returns structured Google results from a single POST:
import requests
from langchain_core.tools import tool
LINKSC_KEY = "lsc_YOUR_KEY"
@tool
def web_search(query: str) -> str:
"""Search the web for current information.
Returns the top results with title, URL, and description."""
r = requests.post(
"https://api.link.sc/v1/search",
headers={"x-api-key": LINKSC_KEY},
json={"q": query},
timeout=60,
)
results = r.json().get("serpData", {}).get("results", [])[:5]
return "\n\n".join(
f"[{i+1}] {res['title']}\n{res['targetUrl']}\n{res['description']}"
for i, res in enumerate(results)
)
Note that the tool returns a formatted string, not raw JSON. Models read numbered, labeled text more reliably than nested JSON, and it costs fewer tokens.
Then the companion tool that solves the snippet problem, a page reader that returns full content as clean markdown:
@tool
def read_page(url: str) -> str:
"""Fetch a URL and return the full page content as markdown.
Use this to read a promising result from web_search."""
r = requests.post(
"https://api.link.sc/v1/fetch",
headers={"x-api-key": LINKSC_KEY},
json={"url": url, "format": "markdown"},
timeout=120,
)
return r.json()["content"]
The fetch endpoint handles the ugly parts for you: JavaScript rendering, bot walls, and HTML-to-markdown conversion. It escalates from plain HTTP to a stealth browser only when a site requires it, so easy pages stay fast and hard pages still come back as text. The docs cover the extra options like screenshots and JSON extraction.
Wiring the tools into an agent
With LangChain 1.0, create_agent is the whole agent loop:
from langchain.agents import create_agent
agent = create_agent(
model="anthropic:claude-sonnet-4-5",
tools=[web_search, read_page],
system_prompt=(
"You are a research assistant. Use web_search to find sources, "
"then read_page on the most promising results before answering. "
"Answer only from content you have read, cite the URL for each "
"claim, and say so if the sources do not contain the answer."
),
)
result = agent.invoke(
{"messages": [{"role": "user",
"content": "What changed in the latest LangChain release?"}]}
)
print(result["messages"][-1].content)
Run it and the trace is exactly the search-then-read pattern: the model calls web_search, scans the five results, calls read_page on one or two URLs, and writes an answer with citations pulled from pages it actually read.
If you are on an older LangChain, the same two tools drop into create_tool_calling_agent plus AgentExecutor unchanged. Tools are the stable part of the API; only the agent constructor has churned. And if you have moved to LangGraph for custom agent topologies, the tool definitions carry over there as is too.
The system prompt is half the feature
The tools give the agent the ability to search. The system prompt gives it the discipline to use search instead of memory. Without the "answer only from content you have read" instruction, models happily call the search tool, glance at the results, and then answer from training data anyway. In my experience this single line changes behavior more than any tool tweak.
Requiring per-claim citations does double duty: users can verify answers, and you can catch hallucinations in review, because a claim with no matching source sticks out immediately.
Two knobs worth tuning
Result count. Five search results is a good default. Ten sounds more thorough but mostly burns tokens on results the model never reads.
What you feed back. Markdown, not HTML. A rendered page is often 100KB of markup wrapping 4KB of prose, and your token bill scales with what the tool returns. I covered the numbers in this post on token optimization for web data.
Cost-wise, the setup above uses roughly one link.sc credit per search or fetch, and the free tier includes 500 credits a month. That is enough to build and evaluate a real agent before paying anything, which beats discovering rate limits mid-demo.
Give your LangChain agent live web access: grab a free API key at link.sc/register and get 500 credits a month.