---
title: "Add Live Web Search to an OpenAI Agent with Tool Calls"
description: "Give an OpenAI agent live Google results with function calling: one web_search tool, a Python loop, and a hard cap of 1 credit per search."
url: https://perceptlayer.com/blog/openai-agent-web-search-tool-call
---

[Blog](https://perceptlayer.com/blog) / [AI agents](https://perceptlayer.com/blog/category/ai-agents)

# Add Live Web Search to an OpenAI Agent with Tool Calls

5 Aug 2026 Perceptlayer 9 min read

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.

## Key takeaways

-   One function tool, `web_search`, maps to one endpoint: `GET /v1/google-search/search` with `query`, `hl` and `gl`. Each successful search costs 1 credit.
-   Trim the response before the model sees it. Keep `position`, `title`, `link`, `snippet` and `source` from `organic_results`, and drop the long `redirect_link` strings.
-   Cap the loop twice: a maximum number of model rounds and a maximum number of searches. With a cap of 5 searches, one question costs at most 5 credits.
-   Return API errors to the model as tool output. Failed calls return `X-Credits-Used: 0`, so a bad query costs nothing and the model can correct itself.
-   The 1,000 free credits you get every month cover 200 questions at the 5-search cap, or more when the model searches less.

## 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](https://platform.openai.com/docs/guides/function-calling)). 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:

1.  Send the messages and the tool definitions to the model.
2.  If the reply has no tool calls, you have the answer.
3.  If it has tool calls, run each one and append the output as a `tool` message.
4.  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](https://perceptlayer.com/blog/mcp-vs-function-calling-web-data).

## What do you need before you start?

You need Python 3, the [OpenAI Python SDK](https://github.com/openai/openai-python), `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](https://perceptlayer.com/signup) and read [authentication](https://docs.perceptlayer.com/authentication) if you haven't called the API before.

Install the two packages and set three environment variables:

```bash
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](https://perceptlayer.com/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](https://perceptlayer.com/apis/google-search/search) lists every optional parameter, and the [Google Search API](https://perceptlayer.com/apis/google-search) 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:

```bash
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](https://perceptlayer.com/blog/paginate-web-data-api-python).

For a closer look at this endpoint outside an agent, see [Google Search API: get search results as JSON](https://perceptlayer.com/blog/google-search-api-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:

```json
{
  "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](https://perceptlayer.com/blog/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.

```python
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](https://perceptlayer.com/blog/retry-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](https://docs.perceptlayer.com/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](https://docs.perceptlayer.com/errors).

**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`:

```python
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](https://perceptlayer.com/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](https://perceptlayer.com/blog/ai-agent-web-data-costs) covers per-session and per-day caps, and [caching API responses](https://perceptlayer.com/blog/cache-web-data-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](https://docs.perceptlayer.com/ai-agents) and [MCP server for web scraping](https://perceptlayer.com/blog/mcp-server-for-web-scraping) are the better start. For the same idea on a different model, see [Claude MCP web search](https://perceptlayer.com/blog/claude-agent-live-search-mcp), and for a LangChain version of this tool, [a LangChain custom tool for live web data](https://perceptlayer.com/blog/langchain-custom-tool-web-data).

To try the endpoint before wiring it up, run a call in the playground on the [Google Search API page](https://perceptlayer.com/apis/google-search), then paste your `sk_live_` key into the script above.

## APIs in this post

-   [Google Search **1 credit per call** · 7 endpoints](https://perceptlayer.com/apis/google-search)

## 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](https://docs.perceptlayer.com/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.

openai function calling web search python ai agents

## More from the blog

1.  [AI agents](https://perceptlayer.com/blog/category/ai-agents) 11 Sept 2026 Perceptlayer 8 min read
    
    ## [OpenAI Agents SDK with a Remote MCP Server for Web Data](https://perceptlayer.com/blog/openai-agents-sdk-mcp-server)
    
    Connect the OpenAI Agents SDK to a Streamable HTTP MCP server with a Bearer header, cap call\_endpoint at 3 calls a question, and count credits per run.
    
2.  [AI agents](https://perceptlayer.com/blog/category/ai-agents) 23 Oct 2026 Perceptlayer 8 min read
    
    ## [Build a Market Research Agent with Search and Trends Data](https://perceptlayer.com/blog/market-research-agent-search-trends)
    
    Wire Google Search, Trends and News into one Python agent, cap it at 8 credits per question, and see a recorded Trends response.
    
3.  [AI agents](https://perceptlayer.com/blog/category/ai-agents) 2 Oct 2026 Perceptlayer 7 min read
    
    ## [How to Add an MCP Server to Claude Desktop and Cursor](https://perceptlayer.com/blog/add-mcp-web-data-tools-claude-desktop)
    
    Add the Perceptlayer MCP server to Claude Desktop or Cursor with an API key header, run a first Google search, and check only successful calls cost credits.
