# OpenAI Agents SDK web search tool with Mux

> Build an OpenAI Agents SDK web search tool with @function_tool or tool() that calls Mux for Exa and Parallel results, with provider choice and per-key budgets.

Canonical: https://getmux.io/guides/web-search-tool-openai-agents-sdk


An OpenAI Agents SDK web search tool can be either OpenAI's hosted `WebSearchTool`, which runs inside the Responses API, or a function tool you write that calls a search API of your choice. This guide builds the second kind on Mux: a `@function_tool` in Python or `tool()` in TypeScript that searches Exa, Parallel and Keenable on one `MUX_API_KEY`, returns compact results, and plugs into `Agent` and `Runner` like any other tool. It targets `openai-agents` 0.24 (Python) and `@openai/agents` 0.20 (TypeScript).

## Hosted web search vs. a function tool

The hosted tool is the easiest option when you want search with no code. A function tool is the better fit when you want control over what gets searched and what comes back.

| | Hosted `WebSearchTool` / `webSearchTool()` | Function tool calling Mux |
|---|---|---|
| Search provider | OpenAI's | You pick: `exa/*`, `parallel/*`, `keenable/*`, with fallback order |
| Pricing | Per OpenAI's pricing for the tool, see their pricing page | Provider list price per search, passed through by Mux, for example $0.001 on `parallel/fast` or $0.007 on `exa/auto` |
| Results | Used by the model inside the response, with citations | Raw JSON you can filter, rank, log, cache or trim before the model sees it |
| Filters | Domain allow-list and location options | `include_domains`, `exclude_domains`, `published_after`, `published_before`, `max_results`, full page text |
| Works with non-OpenAI models | No, it needs the Responses API | Yes, with any model the SDK supports |
| Budget | Your OpenAI bill | Per-key USD limit with daily, weekly or monthly reset |

For OpenAI's current pricing and options, see [their pricing page](https://openai.com/api/pricing/). The rest of this guide covers the function tool.

## Setup

```bash
# Python
uv add openai-agents getmux
# TypeScript
npm install @openai/agents zod getmux

export OPENAI_API_KEY="sk-..."
export MUX_API_KEY="mux-..."
```

Get a Mux key with a few dollars of credit from the [dashboard](https://getmux.io/dashboard/keys). Versions used:

| Package | Version |
|---|---|
| `openai-agents` (Python) | 0.24.0 |
| `@openai/agents` (TypeScript) | 0.20.0 |
| `zod` | 4.6.7 (the SDK requires zod 4) |
| `getmux` | 0.1.0 |
| `pydantic` | 2.14.1 |

## Define the tool

In Python, `@function_tool` builds the JSON schema from type hints and reads the docstring for the tool and argument descriptions, so a Pydantic model is only needed for extra constraints. The example uses `Field` for the bounds on `max_results`. In TypeScript, `tool()` takes a Zod schema in `parameters`. Both expose `query`, an optional `max_results` and an optional `include_domains`, and both return title, URL, snippet and publish date only, which keeps the model's context small.

```python
# web_search.py
from typing import Annotated

from agents import function_tool
from getmux import AsyncMux, MuxError
from pydantic import Field

mux = AsyncMux()  # reads MUX_API_KEY


@function_tool
async def web_search(
    query: str,
    max_results: Annotated[int, Field(ge=1, le=20)] = 5,
    include_domains: list[str] | None = None,
) -> dict:
    """Search the web for current information.

    Args:
        query: The search query, phrased like a web search.
        max_results: How many results to return (1 to 20).
        include_domains: Only return results from these domains, e.g. ["arxiv.org"].
    """
    try:
        res = await mux.search(
            query,
            models=["parallel/fast", "exa/auto"],
            max_results=max_results,
            include_domains=include_domains,
        )
    except MuxError as e:
        # Return the failure as data so the agent can explain it rather than abort.
        return {"error": e.code, "message": e.message, "results": []}

    return {
        "model": res.model,
        "results": [
            {
                "title": r.title,
                "url": r.url,
                "snippet": r.snippet,
                "published_date": r.published_date,
            }
            for r in res.results
        ],
    }
```
```ts
// web-search.ts
import { tool } from "@openai/agents";
import { z } from "zod";
import { Mux, MuxError } from "getmux";

const mux = new Mux(); // reads MUX_API_KEY

export const webSearch = tool({
  name: "web_search",
  description:
    "Search the web for current information. Returns titles, URLs, short snippets and publish dates.",
  parameters: z.object({
    query: z.string().min(1).max(2000).describe("The search query, phrased like a web search"),
    maxResults: z.number().int().min(1).max(20).nullable().describe("How many results to return (null means 5)"),
    includeDomains: z
      .array(z.string())
      .nullable()
      .describe("Only return results from these domains, e.g. ['arxiv.org']"),
  }),
  execute: async ({ query, maxResults, includeDomains }) => {
    try {
      const res = await mux.search({
        query,
        models: ["parallel/fast", "exa/auto"],
        maxResults: maxResults ?? 5,
        includeDomains: includeDomains ?? undefined,
      });
      return {
        model: res.model,
        results: res.results.map((r) => ({
          title: r.title,
          url: r.url,
          snippet: r.snippet,
          published_date: r.publishedDate,
        })),
      };
    } catch (e) {
      if (e instanceof MuxError) {
        return { error: e.code, message: e.message, results: [] };
      }
      throw e;
    }
  },
});
```

The TypeScript schema uses `.nullable()` rather than `.optional()` for the two optional arguments because the SDK uses strict JSON schema with Zod, where every property is required and "optional" is expressed as nullable. Python handles this for you: defaults and `| None` annotations are converted to a strict schema automatically.

`models: ["parallel/fast", "exa/auto"]` tries Parallel first and falls back to Exa if Parallel errors or is at capacity. You pay only for the model that answered; failed attempts are refunded. Mux also enforces `include_domains` after the response, so the filter holds regardless of which provider served the request.

## Run the agent

```python
# agent.py
import asyncio

from agents import Agent, Runner
from web_search import web_search

agent = Agent(
    name="Researcher",
    instructions="Use web_search for current facts and cite the URL of every source you use.",
    tools=[web_search],
)


async def main() -> None:
    result = await Runner.run(
        agent,
        "What changed in the EU AI Act implementation timeline this month?",
        max_turns=6,
    )
    print(result.final_output)


asyncio.run(main())
```
```ts
// agent.ts
import { Agent, run } from "@openai/agents";
import { webSearch } from "./web-search";

const agent = new Agent({
  name: "Researcher",
  instructions: "Use web_search for current facts and cite the URL of every source you use.",
  tools: [webSearch],
});

const result = await run(agent, "What changed in the EU AI Act implementation timeline this month?", {
  maxTurns: 6,
});
console.log(result.finalOutput);
```

`Runner.run` / `run` drives the loop: the model requests `web_search`, the SDK executes it, feeds the JSON back, and repeats until the model produces a final answer or hits `max_turns`. `result.new_items` (Python) or `result.newItems` (TypeScript) contains the tool calls and outputs if you want to build a sources list from the URLs the agent actually fetched.

## Variations

- **Recency.** Add a `published_after: str | None` argument and pass it to `mux.search`. Dates are `YYYY-MM-DD` on every provider. `published_before` is not applied by Parallel models.
- **Full page text.** `content="full"` returns page text on Exa and joined excerpts on Parallel. Put it in a separate `read_page` tool that takes one URL via `include_domains` plus a specific query, so the main search tool stays small.
- **Quality first.** `["exa/auto", "parallel/advanced"]` for deeper research; `["parallel/turbo", "exa/instant"]` for latency-sensitive chat. See the [models page](https://getmux.io/docs/models).

(Live price table: https://getmux.io/models or GET https://api.getmux.io/v1/models)

## Cost control

`max_turns` caps model turns, not searches, and a single turn can contain several parallel tool calls. The hard stop is a per-key credit limit on the Mux side. Create a dedicated key for the agent with a management key (`mux-mgmt-...`):

```python
import os
from getmux import Mux

admin = Mux(api_key=os.environ["MUX_MGMT_KEY"])
secret, key = admin.keys.create("openai-agent", limit=10, limit_reset="daily")
# Put `secret` in MUX_API_KEY for the agent process. It is shown once.
```
```ts
import { Mux } from "getmux";

const admin = new Mux({ apiKey: process.env.MUX_MGMT_KEY });
const { key } = await admin.keys.create({ name: "openai-agent", limit: 10, limitReset: "daily" });
// Put `key` in MUX_API_KEY for the agent process. It is shown once.
```

Once the key reaches $10 for the day, Mux returns `402 key_limit_exceeded` and charges nothing. The check is atomic with the debit, so parallel tool calls cannot overshoot. One key per agent, or per end customer, also gives you a per-key usage breakdown in the dashboard. See [managing keys](https://getmux.io/docs/keys).

## Handling 402 and 429

The `getmux` clients retry `429` and most `5xx` responses twice with backoff and honor `Retry-After`. What remains:

| Error | Meaning | In the tool |
|---|---|---|
| `402 insufficient_credits` | Account balance is empty | Return an error object; alert an operator to [add credits](https://getmux.io/docs/credits). |
| `402 key_limit_exceeded` | This key hit its daily, weekly or monthly limit | Return an error object; raise the limit or wait for the reset. |
| `429 rate_limited` | Over 20 requests per second on one key | Retried by the SDK. If it persists, lower concurrency. |
| `429 too_many_concurrent_requests` | Over 50 searches in flight on the account | Retried by the SDK. Queue tool calls across agents. |
| `502 all_providers_failed` | Every model in `models` failed | Not retried. Safe to retry once; nothing was charged. |

The examples return `MuxError` as data. The Agents SDK also has a `failure_error_function` option on `@function_tool` that converts exceptions into a message for the model, but handling `MuxError` explicitly lets you distinguish billing errors (stop and alert) from transient ones (tell the model to try again later). Any other exception still propagates. The full list of codes is on the [errors page](https://getmux.io/docs/errors).

## FAQ

### When should I use OpenAI's hosted web search instead?

When you want zero search code, you are already on the Responses API with an OpenAI model, and you do not need to inspect or filter raw results. The hosted tool handles citations for you. Use a function tool when you want to choose the index, filter by domain or date before the model sees anything, log or cache results, or run the same agent on non-OpenAI models.

### Does the tool work with the Realtime or voice agents?

Function tools are shared across the SDK, so yes. Keep `max_results` low in voice contexts, since every result is read into the model's context before it speaks.

### Can I run many agents in parallel against one Mux account?

Yes, within the limits of 20 requests per second per key and 50 searches in flight per account. Beyond that you get `429` responses that cost nothing and that the SDK retries. Give each agent its own key so one noisy agent cannot consume another's rate budget.

### Is a direct provider account ever the better choice?

If you only use one provider at very high volume and have negotiated pricing with them, call them directly, or store that key in Mux with [BYOK](https://getmux.io/docs/byok) so the provider bills you and Mux adds no fee for the first 1,000,000 BYOK requests each month.

Get a key and run your first search in the [quickstart](https://getmux.io/docs/quickstart).
