Add Live Web Search to an OpenAI Agent with Tool Calls
To give an OpenAI agent live web search, declare a web_search function in the tools list, run the model, and when it returns a tool call, forward the arguments to GET /v1/google-search/search. Send the trimmed results back as a tool message and call the model again. The loop below does that in about 100 lines of Python, caps the spend at 5 credits per question, and costs nothing when a search fails.
How does function calling add web search to an OpenAI agent?
A model can't browse by itself. With function calling, you describe a function in JSON Schema, the model replies with the function name and arguments instead of text, your code runs it, and you return the result so the model can write the answer (OpenAI function calling guide). Web search is one such function: the model decides when it needs fresh facts and what to query.
The model never touches the network. Your code makes the HTTP request, so you control the key, the timeout, the result size and the budget. That control is the reason to write the loop yourself rather than rely on a hosted search tool.
The shape of the loop is always the same:
- Send the messages and the tool definitions to the model.
- If the reply has no tool calls, you have the answer.
- If it has tool calls, run each one and append the output as a
toolmessage. - Go back to step 1.
Steps 3 and 4 are where cost goes wrong, because a model can keep searching. The code below puts hard limits on that part. For the wider question of when to use a tool call and when to use a protocol, see MCP vs function calling for live web data.
What do you need before you start?
You need Python 3, the OpenAI Python SDK, requests, an OpenAI key, and a Perceptlayer API key. The Perceptlayer key starts with sk_live_ and goes in the X-API-Key header; get a free API key and read authentication if you haven't called the API before.
Install the two packages and set three environment variables:
pip install openai requests
export OPENAI_API_KEY="sk-..."
export OPENAI_MODEL="<a model that supports tool calls>"
export API_KEY="sk_live_..."
The model name is read from OPENAI_MODEL so the script doesn't go stale when model names change. Any chat model with tool-call support works with the code below.
Every account gets 1,000 free credits each month after verifying an email address, with no credit card. A successful search costs 1 credit, so you can run the whole tutorial many times for free. Pricing has what credits cost once you outgrow that.
Which endpoint should the tool call?
Use GET /v1/google-search/search. It returns Google results as JSON, costs 1 credit, and takes one required parameter, query. The Search endpoint page lists every optional parameter, and the Google Search API has six other endpoints, including AI Overviews and autocomplete.
Here is the request the tool will make, with the parameters the model is allowed to set:
curl "https://api.perceptlayer.com/v1/google-search/search?query=best+restaurants+in+Berlin&hl=en&safe=off" \
-H "X-API-Key: $API_KEY"
| Tool argument | Endpoint parameter | Type | Notes |
|---|---|---|---|
query |
query |
string | Required. |
hl |
hl |
string | Optional. Interface language, up to 2 characters. |
gl |
gl |
string | Optional. Country, up to 2 characters. |
The endpoint accepts many more parameters (tbs, location, page and others). Leave them out of the tool schema. Every parameter you expose is another thing the model can set wrongly, and a short schema makes tool calls more reliable. If you later want the agent to page through results, add page yourself and see paginating a web data API.
For a closer look at this endpoint outside an agent, see Google Search API: get search results as JSON.
What does the response look like, and what should the model see?
The search endpoint returns a large JSON object. The recorded response for best restaurants in Berlin has organic_results, related_searches, people_also_search_for, related_questions, local_results and more. The model needs very little of that, and every extra field is tokens you pay for on every later round of the loop.
The useful part is organic_results. This excerpt is from the recorded response:
{
"search_information": {
"query_displayed": "best restaurants in Berlin",
"organic_results_count": 6
},
"organic_results": [
{
"position": 1,
"title": "The Best Restaurants in Berlin - Food Guide & Map",
"link": "https://andershusa.com/best-restaurants-berlin-germany-guide/",
"redirect_link": "https://www.google.com/goto?url=CAESegHrOzAV7vOTOiRYGN3Rq_LJMgUCSUtmes2rLIk-rn2vHS6tGU7IIN38aZ1xjQeD3kZuDFfbW6QvUSum1Woyp6Hmrl5u9WEXLlEP2Ek60dzztg2yT13pxwC2ptSy…",
"displayed_link": "https://andershusa.com › best-restaurants-berlin-german...",
"snippet": "Dec 3, 2025 — Tim Raue is the most internationally renowned restaurant in Berlin. It holds two Michelin stars, has been frequently ranked on The World's 50 ...",
"source": "Anders & Kaitlin",
"rank_group": 1,
"rank_absolute": 4
},
…
],
…
}
Each result has a redirect_link that is a long opaque string, plus displayed_link, rank_group and rank_absolute. None of those help a model write an answer. The tool function keeps five fields per result and caps the count at five results:
| Field kept | Why the model needs it |
|---|---|
position |
Lets the model say which result it used. |
title |
Tells it what the page is. |
link |
The URL it can cite to the user. |
snippet |
The text it answers from. |
source |
The site name, for attribution. |
The snippet is all the model gets. It is Google's excerpt, not the page, which keeps rounds cheap but means the agent can't quote a page body. If you need full pages, that's a different tool (see RAG with live web data).
How do you write the web_search tool function?
The function sends the request, reads the credit headers, and returns a small dictionary that is always JSON-serialisable, including on failure. Returning errors instead of raising means a bad call becomes information for the model, not a crash in your loop.
Save this as agent.py. It is complete; the next section adds the loop.
import json
import os
import time
import requests
from openai import OpenAI
client = OpenAI()
MODEL = os.environ["OPENAI_MODEL"]
BASE_URL = "https://api.perceptlayer.com"
HEADERS = {"X-API-Key": os.environ["API_KEY"]}
MAX_ROUNDS = 6 # model calls per question
MAX_SEARCHES = 5 # credits per question, at 1 credit per search
MAX_RESULTS = 5 # results returned to the model
TOOLS = [
{
"type": "function",
"function": {
"name": "web_search",
"description": (
"Search Google and return the top results with title, URL and "
"snippet. Use it for facts that may have changed recently or "
"that you are not sure about. Each call costs one credit."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query, as you would type it into Google.",
},
"hl": {
"type": "string",
"description": "Two-letter interface language, for example en.",
},
"gl": {
"type": "string",
"description": "Two-letter country code, for example de.",
},
},
"required": ["query"],
},
},
}
]
spend = {"searches": 0, "credits_used": 0, "credits_remaining": None}
def web_search(query, hl="en", gl=None):
if not query:
return {"error": "query is required"}
params = {"query": query, "hl": hl}
if gl:
params["gl"] = gl
response = None
for attempt in range(2):
try:
response = requests.get(
f"{BASE_URL}/v1/google-search/search",
params=params,
headers=HEADERS,
timeout=30,
)
except requests.Timeout:
# The reply may have been lost after the call succeeded, so do not retry.
return {"error": "search timed out; try a different query or answer without it"}
if response.status_code in (429, 503) and attempt == 0:
time.sleep(int(response.headers.get("Retry-After", "1")))
continue
break
spend["searches"] += 1
spend["credits_used"] += int(response.headers.get("X-Credits-Used", 0))
spend["credits_remaining"] = response.headers.get("X-Credits-Remaining")
if response.status_code != 200:
try:
error = response.json()["error"]
except (ValueError, KeyError):
error = {"code": "unknown", "message": f"HTTP {response.status_code}"}
return {"error": error.get("code"), "message": error.get("message")}
results = response.json().get("organic_results", [])[:MAX_RESULTS]
return {
"results": [
{
"position": r.get("position"),
"title": r.get("title"),
"link": r.get("link"),
"snippet": r.get("snippet"),
"source": r.get("source"),
}
for r in results
]
}
Four details matter here.
The retry is narrow. A 429 or 503 carries a Retry-After header with the seconds to wait, so the function waits once and tries again. It does not retry a timeout. Requests have no idempotency key, so a request whose reply was lost may have succeeded and been billed. A second attempt could charge twice. The full pattern is in retrying web API calls without paying twice.
Credits come from headers. Every successful response carries X-Credits-Used and X-Credits-Remaining, and errors carry X-Credits-Remaining too. Summing X-Credits-Used gives the real spend rather than an estimate. See credits for how billing works.
Errors go to the model. An invalid_parameters (422) tells the model its arguments were wrong. A rate_limited (429) that survives the retry tells it to stop searching. Both are in the error reference.
The description tells the model the cost. Saying "each call costs one credit" in the tool description nudges the model to search once with a good query rather than five times with bad ones. It is a nudge, not a guarantee, which is why the loop has hard limits too.
How do you write the tool-call loop?
The loop sends the conversation to the model, executes any tool calls it returns, appends each result as a tool message keyed by tool_call_id, and repeats until the model answers in plain text. Two counters, MAX_ROUNDS and MAX_SEARCHES, guarantee it ends and bound the spend.
Append this to agent.py:
SYSTEM = (
"You answer questions using web search when the answer may be recent or you "
"are unsure. Search with specific queries. Cite the URLs you used. If search "
"results are missing or an error is returned, say so instead of guessing."
)
def run_tool(call):
if call.function.name != "web_search":
return {"error": f"unknown tool {call.function.name}"}
try:
args = json.loads(call.function.arguments or "{}")
except json.JSONDecodeError:
return {"error": "arguments were not valid JSON"}
if spend["searches"] >= MAX_SEARCHES:
return {"error": "search limit reached; answer with the results you already have"}
return web_search(
query=args.get("query", ""),
hl=args.get("hl") or "en",
gl=args.get("gl"),
)
def ask(question):
spend.update(searches=0, credits_used=0, credits_remaining=None)
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": question},
]
for _ in range(MAX_ROUNDS):
completion = client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS
)
message = completion.choices[0].message
messages.append(message)
if not message.tool_calls:
return message.content
for call in message.tool_calls:
result = run_tool(call)
messages.append(
{
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
}
)
return "Stopped: round limit reached before the model gave an answer."
if __name__ == "__main__":
question = input("Question: ")
print(ask(question))
print(
f"\nSearches: {spend['searches']}, "
f"credits used: {spend['credits_used']}, "
f"credits remaining: {spend['credits_remaining']}"
)
Run it with python agent.py and ask something the model can't know from training, such as the current top results for a topic. The last line prints the searches made and the credits those cost, read from the response headers.
A model can return several tool calls in one reply. The loop runs them one after another, which suits the free tier's rate of 1 request a second. When the search limit is hit mid-reply, the remaining calls get an error result instead of a request, so every tool_call_id still gets an answer. An unanswered ID makes the next model call fail.
What does one question cost?
Each successful search costs 1 credit, and MAX_SEARCHES is the ceiling, so one question costs at most 5 credits with the settings above. A question that needs one search costs 1 credit. A search that fails costs 0 and still counts toward the limit, which keeps the cap conservative.
Credits are the only Perceptlayer cost. OpenAI bills tokens separately, and that bill grows with every round because each round re-sends the whole conversation, including earlier search results. Trimming results to five short entries is what keeps that second bill small.
| Setup | Searches per question | Credits per question |
|---|---|---|
| Model searches once | 1 | 1 |
| Model searches three times | 3 | 3 |
| Model hits the cap | 5 | 5 |
Worked example: the free tier's 1,000 credits ÷ 5 credits at the cap = 200 questions a month, and more when the model searches less. At 10,000 questions averaging 2 searches, the arithmetic is 10,000 × 2 × 1 credit = 20,000 credits. Check pricing for what a pack of credits costs. Purchased credits last 12 months, and the service is prepaid: when the balance runs out, calls return insufficient_credits (402) and nothing more is debited.
If you need a budget across many questions, not just one, keep a counter outside ask. AI agent API cost control covers per-session and per-day caps, and caching API responses covers skipping repeat searches entirely.
When should you use MCP instead?
Use a hand-written tool when you want control over the schema, the trimming and the budget, as in this tutorial. Use the MCP server when you want an agent to discover endpoints itself, or when the client already speaks MCP. The server is at https://api.perceptlayer.com/mcp and exposes search_endpoints, get_endpoint and call_endpoint.
The trade-off is tokens and predictability. A single web_search tool is one small schema and one known endpoint. MCP exposes the whole catalogue, which is more flexible but gives the model more ways to spend credits. Lookups with search_endpoints and get_endpoint cost nothing, but call_endpoint spends the endpoint's credits on success.
If your agent only ever searches Google, the function above is simpler. If it needs reviews, listings and prices from different websites, adding tools one by one gets tedious, and the AI agents docs and MCP server for web scraping are the better start. For the same idea on a different model, see Claude MCP web search, and for a LangChain version of this tool, a LangChain custom tool for live web data.
To try the endpoint before wiring it up, run a call in the playground on the Google Search API page, then paste your sk_live_ key into the script above.
APIs in this post
Frequently asked questions
Does the OpenAI model search the web by itself with function calling?
No. The model only returns a function name and arguments. Your code makes the HTTP request and returns the result. That's why the loop above holds the API key, the timeout, the result trimming and the search cap, and the model never sees your credentials.
What happens if a search fails?
The function returns the error code and message as the tool output, and the model can rephrase or answer without the search. Failed calls cost nothing and return X-Credits-Used: 0. Rate-limited (429) and temporarily unavailable (503) responses include a Retry-After header, which the function honours once.
How many requests a second can the loop make?
Limits are per account and counted per minute. Free accounts get 1 request a second (60 a minute); paying accounts run at the rate of the credits they are using, and at least 5 a second. The loop runs tool calls one at a time, so a single user stays within the free rate. See rate limits.
Can the agent get the full text of a page?
Not with this tool. The search endpoint returns Google's snippet for each result, not the page body. The agent can cite the link, and you can add a second tool for fetching a page if your use case needs one. Data is returned as found at the time of the call.
Why cap searches when the model usually stops on its own?
Usually is not always. A model can repeat near-identical queries when results look thin, and each one is a credit and another round of tokens. The MAX_ROUNDS and MAX_SEARCHES limits make the worst case a known number, 5 credits per question here, instead of an open-ended loop.