DEV Community

Decodo for Decodo

Posted on

How To Build an AI Agent That Browses the Web?

Your model provider probably includes a web search tool. It's one line of code, and for a stable fact, it works. It goes wrong on a page that changes or blocks bots, and it rarely warns you. This guide builds the other kind of agent: one tool that fetches a page through Decodo's Web Scraping API, and a short loop that runs the tool.

TL;DR

  • Use a fetch tool when the answer changes faster than an index can follow, and leave stable facts to search.
  • Define one tool, fetch_page(url), that calls Decodo's Web Scraping API with markdown: true on the premium pool, and returns truncated text instead of HTML.
  • Send the question and the tool definition together, then check whether the response contains a function call rather than an answer.
  • Run fetch_page when the model asks, append the result to the conversation, and repeat until the model answers or the turns run out.

Do you need a fetch tool?

One question for each outcome, run both ways:

Question Built-in web_search result Live fetch result Result
Price of Sharp Objects on books.toscrape.com £47.82 £47.82 Both correct
Latest Python version on python.org 3.14.6, 4 runs out of 4 3.14.7 Search stale
Latest Node.js LTS on nodejs.org 24.18.0 or 24.18.1 across 3 runs 24.19.0 Search stale
Ranked restaurants on a page that 403s a plain request 1 real, 2 hallucinated, missed the top entry the correct top 6 Search hallucinated

For a stable fact, a one-line search tool answers correctly, and a fetch loop is wasted work. In the rest, search answered incorrectly without warning, twice from a stale index and once from invented entries.

A plain requests.get() handles the first 3 rows at low volume. The API adds the proxy rotation that the last row needs.

What you'll need

  • Python 3.11 or newer. The libraries still run on 3.10, but 3.10 loses support in October 2026.
  • A Decodo account with Web Scraping API credentials. The free plan is enough to follow along.
  • An OpenAI API key from the platform dashboard. The script sets the model in one constant, so you can use another OpenAI model with function calling.

Install both libraries:

pip install requests openai
Enter fullscreen mode Exit fullscreen mode

Activate a plan, and the Playground tab shows your Basic authentication token:

Decodo's Web Scraping API

The script reads DECODO_TOKEN directly, and OpenAI() reads OPENAI_API_KEY automatically, so set both first:

export DECODO_TOKEN="your_decodo_token"
export OPENAI_API_KEY="your_openai_key"
Enter fullscreen mode Exit fullscreen mode

Run these in the same terminal session you'll start the script from. They don't carry over once you close that session.

Step 1: Define the fetch tool

The agent gets one tool. The tool takes a URL, fetches the page through the API, and returns text the model can read.

Three request-body fields matter here. target: universal is the generic template for any URL. markdown: true converts the HTML before it reaches you, and the parameters reference recommends Markdown for LLM input. proxy_pool has to be premium for the conversion.

Here's the tool:

import os
import requests

API_URL = "https://scraper-api.decodo.com/v2/scrape"
DECODO_TOKEN = os.environ["DECODO_TOKEN"]
MAX_CHARS = 8000

def fetch_page(url):
    response = requests.post(
        API_URL,
        json={
            "target": "universal",
            "url": url,
            "proxy_pool": "premium",
            "markdown": True,
        },
        headers={"Authorization": f"Basic {DECODO_TOKEN}"},
        timeout=120,
    )
    response.raise_for_status()

    data = response.json()
    if "results" not in data:
        return f"Fetch failed: {data.get('message')}"

    return data["results"][0]["content"][:MAX_CHARS]
Enter fullscreen mode Exit fullscreen mode

Markdown alone didn't shrink python.org's downloads page enough. In one fetch, the HTML measured 49,413 tokens on OpenAI's o200k_base tokenizer. The Markdown measured 22,542, roughly half. MAX_CHARS cuts that remainder by another 89%, and the first 8,000 characters measure 2,464 tokens.

The results check handles one specific failure. When the API accepts the request but can't scrape the target, it still returns HTTP 200, with a message and no results key, so raise_for_status() passes. Indexing into results then raises KeyError, which nothing catches, ending the run.

Returning the message as a string lets the model read it and try a different URL. A bad DECODO_TOKEN still raises, because the model can't fix it.

Step 2. Set up the agent loop

The model sees only the tools you put in the request. You describe the tool once, in the format the Responses API expects, then send it with every request:

import json
from openai import OpenAI

client = OpenAI()
MODEL = "gpt-5.4-mini"
MAX_TURNS = 5

TOOLS = [
    {
        "type": "function",
        "name": "fetch_page",
        "description": (
            "Fetch a web page and return its text as Markdown. "
            "Use this whenever the answer depends on what a page says now."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The full URL to fetch."}
            },
            "required": ["url"],
            "additionalProperties": False,
        },
    }
]
Enter fullscreen mode Exit fullscreen mode

The tool's description tells the model when to call it. The parameters field is a JSON schema for the arguments, and the model fills them from the conversation. The name field comes back as call.name.

Nothing in TOOLS runs fetch_page. The Responses API returns the name and the arguments, and your code runs the tool:

def run(question):
    messages = [{"role": "user", "content": question}]

    for _ in range(MAX_TURNS):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS
        )
        message = response.choices[0].message
        messages.append(message)

        if not message.tool_calls:
            return message.content

        for call in message.tool_calls:
            args = json.loads(call.function.arguments)
            print(f"[tool call] {call.function.name}({args})")

            if call.function.name == "fetch_page":
                result = fetch_page(args["url"])
            else:
                result = f"No tool named {call.function.name}."

            print(f"[tool result] {len(result)} characters of Markdown")
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "name": call.function.name,
                "content": result,
            })

    return f"Stopped at the {MAX_TURNS}-turn limit without a final answer."
Enter fullscreen mode Exit fullscreen mode

MAX_TURNS stops the loop after 5 turns. Using the tool costs at least 2 turns, one to ask for the page and one to answer. Count them before running a long list of questions.

Each result carries call.call_id, matching the result to its own call. call.arguments is a JSON string, not a dict.

The else branch matters because a model can ask for a tool you declared and never implemented. With search_web declared and no branch for it, the model called the tool, read the fallback string, then answered that it couldn't use the tool. Raising would have ended the run.

Step 3. Run a real query against a live page

Pick a question whose answer changes faster than anything can index it. Omit the URL. The Hacker News front page reorders through the day, so training data and search indexes lag behind it. The entry point takes the question from the command line:

import sys

if __name__ == "__main__":
    default_question = "What is the top story on Hacker News right now?"
    question = " ".join(sys.argv[1:]) or default_question
    print(f"[question] {question}")
    print(f"[answer] {run(question)}")
Enter fullscreen mode Exit fullscreen mode

With no argument, 1 run printed:

[question] What is the top story on Hacker News right now?
[tool call] fetch_page({'url': 'https://news.ycombinator.com/'})
[tool result] 8000 characters of Markdown
[answer] The current top story on Hacker News is **“My server is a phone now”** with **169 points** and **71 comments**.
Enter fullscreen mode Exit fullscreen mode

The question named no URL, so the model picked where to look. The title came from this line of the Markdown:

[My server is a phone now](https://seg6.space/posts/phone-server/) ([seg6.space](from?site=seg6.space))
Enter fullscreen mode Exit fullscreen mode

When asked the same question with the built-in search tool, the model named 2 different stories across 3 runs, and neither was on the page. Twice, the model said it couldn't see the live front page, then answered.

The full script

Save the script as agent.py, then run python agent.py:

import json
import os
import sys
import requests
from openai import OpenAI

API_URL = "https://scraper-api.decodo.com/v2/scrape"
DECODO_TOKEN = os.environ["DECODO_TOKEN"]
MAX_CHARS = 8000
MAX_TURNS = 5

client = OpenAI()
MODEL = "gpt-5.4-mini"

def fetch_page(url):
    response = requests.post(
        API_URL,
        json={
            "target": "universal",
            "url": url,
            "proxy_pool": "premium",
            "markdown": True,
        },
        headers={"Authorization": f"Basic {DECODO_TOKEN}"},
        timeout=120,
    )
    response.raise_for_status()

    data = response.json()
    if "results" not in data:
        return f"Fetch failed: {data.get('message')}"

    return data["results"][0]["content"][:MAX_CHARS]

TOOLS = [
    {
        "type": "function",
        "name": "fetch_page",
        "description": (
            "Fetch a web page and return its text as Markdown. "
            "Use this whenever the answer depends on what a page says now."
        ),
        "parameters": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The full URL to fetch."}
            },
            "required": ["url"],
            "additionalProperties": False,
        },
    }
]

def run(question):
    messages = [{"role": "user", "content": question}]

    for _ in range(MAX_TURNS):
        response = client.responses.create(model=MODEL, input=messages, tools=TOOLS)
        messages += response.output
        calls = [item for item in response.output if item.type == "function_call"]

        if not calls:
            return response.output_text

        for call in calls:
            args = json.loads(call.arguments)
            print(f"[tool call] {call.name}({args})")

            if call.name == "fetch_page":
                result = fetch_page(args["url"])
            else:
                result = f"No tool named {call.name}."

            print(f"[tool result] {len(result)} characters of Markdown")
            messages.append(
                {
                    "type": "function_call_output",
                    "call_id": call.call_id,
                    "output": result,
                }
            )

    return f"Stopped at the {MAX_TURNS}-turn limit without a final answer."

if __name__ == "__main__":
    default_question = "What is the top story on Hacker News right now?"
    question = " ".join(sys.argv[1:]) or default_question
    print(f"[question] {question}")
    print(f"[answer] {run(question)}")
Enter fullscreen mode Exit fullscreen mode

Before running the script against any site, check the target's robots.txt and terms of service. urllib.robotparser reads robots.txt. The terms of service are a separate check. Copyright and privacy law can apply.

Common pitfalls

  • Sending raw HTML. The conversation carries every earlier tool result, so one large page is re-sent on every later turn. Misspelling markdown sends raw HTML, because the API validates values but not key names.
  • Truncating above the answer. MAX_CHARS cuts blindly at 8,000 characters, and one fetch kept 23 of the 30 front-page stories. Raise the cap when the fact you want sits lower.
  • Assuming the page is server-rendered. JavaScript rendering is important; that's why headless stays off by default. Without it, a client-side rendered page comes back nearly empty, and the call still succeeds. quotes.toscrape.com/js/ returned 172 characters until headless was set to html. The [tool result] line shows the count.
  • Trusting the URL the model returns. That widens the attack surface for indirect prompt injection. The fetched page enters the model's context, so its text can influence the next fetch and the answer you get. An allowlist in the fetch_page branch limits where the agent goes, not what it concludes.
  • Leaving the loop unbounded. The loop exits only on a response with no function call, and a model that keeps calling never sends one. With MAX_TURNS set to 1, the run returns Stopped at the 1-turn limit without a final answer.

These all return success, except the unbounded loop. Watch the character count, not the status code.

Where to take it from here

Add a search tool for stable facts. Decodo's Fast Search API is one option, with its own token.

A second tool is another entry in TOOLS and another branch next to fetch_page. The loop already runs tools as many times as the model asks, one at a time, within the MAX_TURNS limit.

To skip writing the loop, use Decodo's MCP server, which exposes a fetch tool your client can load directly.

Conclusion

Search wins on stable facts. A live fetch wins on pages that change or block bots. Neither reliably tells you when it's wrong. The loop is the easy part. The rest is knowing when to trust the answer.

Top comments (0)