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.
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. The rest of this guide covers the function tool.
Setup#
# 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. 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.
# 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
],
}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#
# 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())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 | Noneargument and pass it tomux.search. Dates areYYYY-MM-DDon every provider.published_beforeis not applied by Parallel models. - Full page text.
content="full"returns page text on Exa and joined excerpts on Parallel. Put it in a separateread_pagetool that takes one URL viainclude_domainsplus 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.
| Model | Per 1,000 requests | Included results | Extra result | Latency |
|---|---|---|---|---|
| exa/instant Exa instant, lowest-latency neural search | $4.00 | 10 | +$1.00 / 1k | ~300 ms |
| exa/fast Exa fast | $7.00 | 10 | +$1.00 / 1k | ~500 ms |
| exa/auto Exa auto (default, best general quality) | $7.00 | 10 | +$1.00 / 1k | ~1 s |
| exa/deep Exa deep multi-query search | $12.00 | 10 | +$1.00 / 1k | ~5 s |
| parallel/turbo Parallel turbo, ~200ms | $1.00 | 10 | +$1.00 / 1k | ~200 ms |
| parallel/fast Parallel fast, <1s | $1.00 | 10 | +$1.00 / 1k | ~800 ms |
| parallel/basic Parallel basic | $5.00 | 10 | +$1.00 / 1k | ~1 s |
| parallel/advanced Parallel advanced, highest quality excerpts | $5.00 | 10 | +$1.00 / 1k | ~3 s |
| keenable/pro Keenable pro index | $4.00 | 10 | none | ~700 ms |
| keenable/realtime Keenable realtime index | $4.00 | 10 | none | ~700 ms |
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-...):
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.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.
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. |
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.
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 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.
Also available as markdown for your coding assistant.
More guides
Add a web search tool to the Vercel AI SDK with Mux
Build a web search tool for the Vercel AI SDK with the tool() helper and multi-step generateText, using Mux for Exa and Parallel results on one key.
Exa pricing explained: what each Exa search tier costs
Exa pricing through Mux: per-request and extra-result math for exa/instant, fast, auto and deep, worked examples, and when your own Exa contract is cheaper.
Keenable search API through Mux: models and pricing
What the Keenable search API offers through Mux: two models, a flat $4 per 1,000 requests for up to 50 results, native date filters, and no page text.