100 free credits on signup — no cardSee pricing

[ blog · tutorial ]8 min read

A LangChain Web Search Tool in 20 Lines (Python)

Sarah ChoyPublished September 23, 20268 min read
A LangChain Web Search Tool in 20 Lines (Python)

LangChain's bundled search wrappers hide the two decisions that actually matter: what shape the results arrive in, and who pays for the calls the agent makes and then throws away. Writing the tool yourself takes twenty lines and puts both back in your hands.

TL;DR

  • @tool from langchain_core turns a plain function into a tool — the docstring becomes the description the model reads.
  • create_react_agent from langgraph.prebuilt is the current path to a working agent; initialize_agent is legacy.
  • Return a compact list of dicts, not the raw API payload — every unused field is tokens you pay for on every step.
  • POST https://www.apipick.com/api/search/web with an x-api-key header: 15 credits per call, charged only on HTTP 200.
  • Add a second tool for reading pages (Extract, 2 credits per URL) so the agent can search broadly and read narrowly.

Why write the tool instead of importing one?

LangChain ships wrappers for several search vendors, and they are fine for a demo. They also make two decisions on your behalf that you will want back the moment the agent goes into a loop: the shape of what comes back, and what a discarded call costs you.

A ReAct agent re-reads its whole message history on every step. A tool that returns a fat payload is not a one-time cost — it is a tax on every subsequent step of that run. Writing the tool yourself is twenty lines and puts the trimming where it belongs.

What does the minimal tool look like?

import os, httpx
from langchain_core.tools import tool

API_KEY = os.environ["APIPICK_KEY"]

@tool
def web_search(query: str) -> list[dict]:
    """Search the live web and return ranked results.

    Use this whenever the answer depends on information after your
    training cutoff, on current prices, on news, or on anything a
    person would check by opening a browser.

    Write the query the way someone would type it into a search box:
    plain words, no operators, no quotes, no site: filters.

    Returns a list of {title, url, snippet}.
    """
    r = httpx.post(
        "https://www.apipick.com/api/search/web",
        headers={"x-api-key": API_KEY},
        json={"query": query, "max_num_results": 5},
        timeout=20,
    )
    r.raise_for_status()
    return [
        {"title": x["title"], "url": x["url"], "snippet": x["snippet"]}
        for x in r.json()["results"]
    ]

How do you bind it to an agent?

create_react_agent from langgraph.prebuilt is the current path. It returns a graph; you invoke it with a message list and it runs the tool loop for you.

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent

llm = init_chat_model("anthropic:claude-sonnet-5")

agent = create_react_agent(
    llm,
    tools=[web_search],
    prompt=(
        "You are a research assistant. Ground every factual claim in a "
        "search result and cite the URL. If search returns nothing "
        "useful, say so rather than guessing."
    ),
)

out = agent.invoke({"messages": [
    {"role": "user", "content": "What changed in the EU AI Act timeline this year?"}
]})
print(out["messages"][-1].content)

initialize_agent, which most older tutorials use, is the legacy path. If you are copying from a 2024 post, this is the line to replace.

Snippets answer "which page". They rarely answer "what does it say". The pattern that works is search broadly, read narrowly: give the agent a second tool that fetches clean text for specific URLs, and let it decide which two or three are worth opening.

@tool
def read_pages(urls: list[str]) -> list[dict]:
    """Fetch the clean readable text of specific web pages.

    Call this after web_search when a snippet is not enough to answer.
    Pass only the URLs that look genuinely relevant — at most three.

    Returns a list of {url, content} with navigation and ads removed.
    """
    r = httpx.post(
        "https://www.apipick.com/api/extract",
        headers={"x-api-key": API_KEY},
        json={"urls": urls[:3]},
        timeout=60,
    )
    r.raise_for_status()
    return [
        {"url": x["url"], "content": x["content"][:6000]}
        for x in r.json()["results"] if x["status"] == "ok"
    ]

agent = create_react_agent(llm, tools=[web_search, read_pages], prompt=...)

Note the [:3] and the [:6000]. Both are guard rails against the model asking for twelve pages and then drowning in them. Search is 15 credits per call and extraction is 2 credits per URL, so the cost of that discipline is small either way — the context window is the scarcer resource.

What does a run actually cost?

  • One search: 15 credits ≈ $0.015.
  • Reading three pages: 6 credits ≈ $0.006.
  • A typical three-search, two-read question: about $0.06 of tool calls, and credits are deducted only on HTTP 200.

That last clause is the one that changes how you write agent code. When failed calls are free you can let the agent explore — speculative searches, a retry after a reformulation — without a meter running on every dead end. See why only-on-success billing changes agent design for the longer argument.

What should you check before shipping?

  • Timeouts on every tool. A hung HTTP call inside a ReAct loop looks like a hung agent.
  • An explicit "say you do not know" instruction. Without it, a bad search result set is the most common trigger for a confident invention.
  • Result caps at the source, not in the prompt. Models negotiate with prompts; they cannot negotiate with max_num_results.
  • Citations in the output. If the agent cannot name the URL a claim came from, the grounding is not doing its job.

Wiring the same tool into other frameworks is largely a translation exercise — see the CrewAI version, the Vercel AI SDK version, and the raw OpenAI and Claude version. Start with a free key: 100 credits, no card.

Frequently Asked Questions

Should I use @tool or subclass BaseTool?

Use @tool. It registers the function name, takes the docstring as the description the model reads, and derives the argument schema from your type hints. Subclassing BaseTool is worth it when you need an explicit Pydantic args_schema, shared state on the instance, or separate sync and async implementations — which is a minority of search tools.

Is initialize_agent still the right way to build the agent?

No. initialize_agent is the legacy path. The current pattern is create_react_agent from langgraph.prebuilt, which returns a graph you invoke with a messages list. It gives you the tool loop, message state, and streaming without you writing the executor.

Why does the docstring matter so much?

Because it is the entire spec the model sees. @tool parses the docstring into the tool description and the type hints into the parameter schema. A docstring that says 'searches the web' produces an agent that searches for the user's literal question; one that says when to use the tool, how to phrase the query, and what comes back produces an agent that searches like a person would.

How do I stop the agent from burning context on search results?

Trim in the tool, not in the prompt. Return only title, url, and snippet, and cap the count at the source — max_num_results accepts 1–5 and defaults to 5. A ReAct agent re-reads the full message history on every step, so a bloated tool result is not paid for once, it is paid for on every subsequent step of that run.

What happens to my bill when the agent retries a failed call?

Nothing, if the provider charges only on success. Credits are deducted on HTTP 200 only, so a timeout, a 401 from a bad key, or a 400 from a malformed argument costs zero. That matters more in agent code than in a script, because a ReAct loop that gets a tool error will usually reformulate and call again rather than stop.

APIs used in this article

Sarah Choy
Written by
Sarah Choy
CEO, API Pick

Sarah Choy is the CEO of API Pick. She writes about building production-ready APIs for AI agents and LLM workflows.