# OpenAI function calling: a read_url tool for your model

> OpenAI function calling with a read_url tool: the JSON schema and the Python loop that turns links into clean Markdown for the model. No KYC, pay in crypto.

- URL: https://webreaderapi.com/docs/openai/
- Last updated: 2026-10-22
- Publisher: WebReaderAPI (https://webreaderapi.com)

Describe one function, `read_url`, and run it when the model asks. The model chooses which pages to read; your code calls the API and hands back the Markdown. The same schema works with other models that support tool calling.

**Quick answer — How do I let an OpenAI model read web pages with function calling?**

Declare a `read_url` function in the `tools` list, then run a loop: when the model returns a tool call, call **WebReaderAPI**'s `/read` endpoint with the URL and send the Markdown back as a `tool` message. Each page costs 1 credit, 2 with a browser, from $0.07/1K pages as of October 2026. No KYC; pay in crypto.

## What does the read_url schema look like?

Two parameters are enough: the `url` to read, required, and an optional `max_chars` that lets the model keep a long page short. The description is the most important part: it tells the model what the tool returns and when to use it. Keep it factual and short.

We name the tool `read_url` rather than `web_fetch` to avoid clashing with tools that some agent runtimes already provide under that name.

*The tool definition*

```json
{
  "type": "function",
  "function": {
    "name": "read_url",
    "description": "Read a public web page and return its main content as Markdown, with its title and source URL. Use it when the user gives a link or when a page must be read to answer.",
    "parameters": {
      "type": "object",
      "properties": {
        "url": { "type": "string", "description": "The http or https address of the page." },
        "max_chars": { "type": "integer", "description": "Most characters to return, 1000 to 500000. Default 20000." }
      },
      "required": ["url"],
      "additionalProperties": false
    }
  }
}
```

## How does the tool-calling loop work?

Send the conversation with the `tools` list. If the reply contains tool calls, run each one, append the result as a message with role `tool` and the matching `tool_call_id`, and ask the model again. When a reply comes back with no tool call, it is the final answer.

Cap the number of rounds. Three or four is plenty for a question about a handful of pages, and the cap protects you from a model that keeps asking for more.

*Python: the full loop with the openai package*

```python
import json
import os

import requests
from openai import OpenAI

READ_URL = {
    "type": "function",
    "function": {
        "name": "read_url",
        "description": "Read a public web page and return its main content as Markdown, with its title and source URL.",
        "parameters": {
            "type": "object",
            "properties": {
                "url": {"type": "string", "description": "The http or https address of the page."},
                "max_chars": {"type": "integer", "description": "Most characters to return. Default 20000."},
            },
            "required": ["url"],
            "additionalProperties": False,
        },
    },
}


def read_url(url, max_chars=20000):
    resp = requests.get(
        "https://webreaderapi.com/api/v1/read",
        headers={"Authorization": "Bearer " + os.environ["WEBREADER_API_KEY"]},
        params={"url": url, "max_chars": max_chars},
        timeout=75,
    )
    body = resp.json()
    if resp.status_code != 200:
        return "Could not read the page: " + body["error"]["code"]
    return "# " + (body.get("title") or url) + "\nSource: " + body["final_url"] + "\n\n" + body["markdown"]


client = OpenAI()
model = os.environ.get("OPENAI_MODEL", "gpt-4.1-mini")
messages = [{"role": "user", "content": "What does https://example.com/blog/first-post announce? Answer in two sentences."}]

for _ in range(4):
    reply = client.chat.completions.create(model=model, messages=messages, tools=[READ_URL])
    msg = reply.choices[0].message
    if not msg.tool_calls:
        print(msg.content)
        break
    messages.append(msg)
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        messages.append({"role": "tool", "tool_call_id": call.id, "content": read_url(**args)})

```

## How do I keep token use and cost down?

Three settings matter. Keep `max_chars` modest, around 8,000 to 20,000 characters per page, because every character you return is read by the model. Keep the default `main_content=true`, so menus and footers never reach the context. And cap the loop, as above, so one question cannot trigger dozens of reads.

On our side, cost is per page, not per character: 1 credit = 1 page read without a browser; 2 credits when the page needs a browser to render JavaScript. A request that fails, or that a site's robots.txt refuses, is not charged. A support assistant that reads 300 pages a day uses about 9,000 credits a month, inside 10,000 pages ($3.75).

## Does the schema work with other models?

Yes, with small changes in the wrapper. Most tool-calling APIs take a JSON Schema for the parameters and a name and description for the tool, so `read_url` moves across providers as is. The [MCP page](https://webreaderapi.com/docs/mcp/) packages the same function for clients that speak the Model Context Protocol, and the [LangChain page](https://webreaderapi.com/docs/langchain/) wraps it for LangChain and LlamaIndex agents.

## What should the model see when a page is refused?

A short, clear sentence, as the loop above returns: "Could not read the page: blocked_by_robots". Models handle that well and fall back to other sources or tell the user. We respect robots.txt: our crawler identifies itself as WebReaderAPI, follows the rules written for it or for all robots, and does not read a page that a site disallows. Refused pages are not charged. If a site refuses our request, we return its answer: we never solve CAPTCHAs, log in to accounts, or try to get around a block or a paywall. None of these refusals uses a credit.

## Function calling: common questions

### Does this need the Responses API or Chat Completions?

The example uses Chat Completions with the `tools` parameter. The same function definition and the same `read_url` body work with other OpenAI interfaces that accept function tools; only the loop around them changes.

### Can the model read several pages in one turn?

Yes. A reply can contain several tool calls; the loop runs each and returns every result before asking the model again. Keep `max_chars` lower when the model reads many pages at once.

### Is the API key exposed to the model?

No. The key stays in your code's environment. The model only sees the tool's name, its description and the text your function returns.

### What does a refused page cost?

Nothing. 1 credit = 1 page read without a browser; 2 credits when the page needs a browser to render JavaScript. A request that fails, or that a site's robots.txt refuses, is not charged.

---

WebReaderAPI: WebReaderAPI is a web reader API for AI agents that turns any public web page into clean Markdown, paid in crypto with no ID upload. Prepaid credits from $0.07/1K pages never expire, and robots.txt is respected. No KYC — no ID, no selfie, no documents. Pay in crypto.
