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.
{
"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.
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 packages the same function for clients that speak the Model Context Protocol, and the LangChain page 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.