← All posts

Build a Chatbot With Web Access

Quick answer: A chatbot with web access is an LLM you have given tools to search and read the live web. When a user asks something the model cannot answer from memory, it calls a search tool, reads the results with a fetch tool, and answers from what it found, with citations. You build it with a tool-use loop: the model requests a tool, your code runs it, you hand back the result, and you repeat until the model has enough to answer. This tutorial builds one in Python.

A plain chatbot is frozen at its training cutoff. Ask it about today's news, a current price, or a page that exists now, and it either guesses or admits it does not know. Web access fixes that by letting the model go look.

The Tool-Use Loop

The whole thing rests on one pattern. The model does not fetch pages itself; it asks your code to. The exchange looks like this:

User question
   -> model decides it needs the web, emits a tool call
   -> your code runs the tool (search or fetch)
   -> you return the result to the model
   -> model either calls another tool or writes the final answer

You loop until the model stops asking for tools. That is it.

Step 1: Define the Web Tools

Two tools cover almost everything: search to find pages, fetch to read one. Both are thin wrappers over link.sc. The contract: search takes q, fetch takes url and format, both hit api.link.sc/v1 with an x-api-key header.

import os
import requests

LINKSC = "https://api.link.sc/v1"
HEADERS = {
    "x-api-key": os.environ["LINKSC_API_KEY"],
    "content-type": "application/json",
}


def run_search(query: str) -> str:
    r = requests.post(
        f"{LINKSC}/search",
        headers=HEADERS,
        json={"q": query, "engine": "google"},
    )
    r.raise_for_status()
    results = r.json()["serpData"].get("organic", [])[:5]
    return "\n".join(f"{x['title']} - {x['link']}" for x in results)


def run_fetch(url: str) -> str:
    r = requests.post(
        f"{LINKSC}/fetch",
        headers=HEADERS,
        json={"url": url, "format": "markdown"},
    )
    r.raise_for_status()
    return r.json()["content"][:8000]

Now describe those tools to the model. The descriptions matter: they tell the model when to reach for each one.

TOOLS = [
    {
        "name": "search",
        "description": (
            "Search the web for current information. Use this when the "
            "question involves recent events, current facts, or anything "
            "you are not certain about from memory. Returns titles and URLs."
        ),
        "input_schema": {
            "type": "object",
            "properties": {"query": {"type": "string"}},
            "required": ["query"],
        },
    },
    {
        "name": "fetch",
        "description": (
            "Fetch the full text of a URL as clean markdown. Use this to "
            "read a page found via search before answering."
        ),
        "input_schema": {
            "type": "object",
            "properties": {"url": {"type": "string"}},
            "required": ["url"],
        },
    },
]

Step 2: Run the Loop

Here is the core. Send the conversation with the tools attached; if the model asks for a tool, run it, append the result, and call again. Keep going until stop_reason is end_turn.

from anthropic import Anthropic

claude = Anthropic()  # reads ANTHROPIC_API_KEY

SYSTEM = (
    "You are a helpful assistant with web access. When a question needs "
    "current or specific information, search and read sources before "
    "answering. Cite the URLs you used. If the web does not answer the "
    "question, say so plainly rather than guessing."
)


def dispatch(name: str, args: dict) -> str:
    if name == "search":
        return run_search(args["query"])
    if name == "fetch":
        return run_fetch(args["url"])
    return f"Unknown tool: {name}"


def chat(messages: list[dict], max_turns: int = 6) -> str:
    for _ in range(max_turns):
        resp = claude.messages.create(
            model="claude-opus-4-8",
            max_tokens=4000,
            thinking={"type": "adaptive"},
            system=SYSTEM,
            tools=TOOLS,
            messages=messages,
        )
        if resp.stop_reason == "end_turn":
            return next(b.text for b in resp.content if b.type == "text")

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.type == "tool_use":
                output = dispatch(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        messages.append({"role": "user", "content": results})

    return "I could not finish researching that within the step limit."

Notice the max_turns cap. That is your first guardrail: without it, a confused model could loop forever, burning tokens and your rate limit.

Step 3: Talk to It

if __name__ == "__main__":
    history = [{"role": "user", "content": "What changed in the latest "
                "Python release, and where did you read it?"}]
    print(chat(history))

Ask something the model cannot know from training, and you will see it search, fetch a release page, and answer with a link. Ask something it already knows and it will just answer, no tools needed. That is the model deciding for itself when the web is worth the round trip.

Grounding and Citations

The reason to give a chatbot web access is not just recency. It is grounding: an answer backed by a source the user can check. Two things make this reliable.

First, the system prompt tells the model to cite URLs and to admit when the web came up empty. That instruction does real work. Without it, a model will happily blend retrieved facts with remembered ones and give you no way to tell which is which.

Second, because fetch returns the actual page text, the model is answering from what the page says, not from a search snippet. Snippets are short and often misleading. The full page is the difference between "the search result implied X" and "the source states X."

Guardrails

A chatbot that can reach the web needs a few limits. None are complicated; all are worth having.

Guardrail Why How
Step cap Stop runaway loops The max_turns limit above
Content truncation Keep the context window sane Slice fetched content (the [:8000])
Domain policy Avoid junk or disallowed sites Filter URLs before fetching
Rate limits Be a good web citizen Respect limits and robots.txt; do not hammer one host
Fetch errors One bad page should not crash the chat Catch HTTPError and return a note instead

On the ethics side: fetch only public pages, respect robots.txt and rate limits, and do not evade logins or paywalls. A chatbot with web access is a tool for reading the open web, not for getting around access controls.

Add error handling to the fetch tool so a single dead link does not break the turn:

def run_fetch(url: str) -> str:
    try:
        r = requests.post(f"{LINKSC}/fetch", headers=HEADERS,
                          json={"url": url, "format": "markdown"})
        r.raise_for_status()
        return r.json()["content"][:8000]
    except requests.HTTPError:
        return f"Could not fetch {url}. Try another source."

Where to Go From Here

You now have a chatbot that searches, reads, and cites. The same tool-use loop scales up: add tools for your own database, your docs, or an internal API, and the model orchestrates all of them. Giving a model tools to reach the outside world is the whole idea behind giving your AI agent internet access, which goes further into the agent side. If you want the model to run many searches autonomously and synthesize a report, real-time web search for LLMs is the next read.

The link.sc docs have the full search and fetch reference, including options for JavaScript rendering and country targeting.


Give your chatbot a live view of the web. Start with link.sc for free.