Web Search Tools for the OpenAI Agents SDK
Use @function_tool to give an Agents SDK agent Serply-backed search, then hand off to a specialist agent that reads the pages.


- Search as a function tool
- A reader tool
- Splitting search from reading
- Structured output
- A guardrail worth adding
The Agents SDK is deliberately thin. @function_tool turns a Python function into a tool by reading its signature and docstring, handoffs let one agent delegate to another, and the runner handles the loop. There’s very little framework to fight with, which puts the weight on how you write the tools.
Search as a function tool
import os
import requests
from urllib.parse import quote_plus
from agents import Agent, Runner, function_tool
API_KEY = os.environ["SERPLY_API_KEY"]
BASE = "https://api.serply.io/v1"
@function_tool
def web_search(query: str, num: int = 10) -> str:
"""Search the live web for current information.
Args:
query: The search query, phrased the way you'd type it into Google.
num: How many results to return, between 1 and 50.
"""
resp = requests.get(
f"{BASE}/search/q={quote_plus(query)}&num={num}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": "US"},
timeout=30,
)
if resp.status_code == 429:
return "Rate limited. Answer with what you have; do not retry."
resp.raise_for_status()
results = resp.json().get("results", [])
if not results:
return f"No results for '{query}'. Try different wording."
return "\n\n".join(
f"[{r.get('position')}] {r.get('title')}\n{r.get('link')}\n{r.get('description', '')}"
for r in results
)
The docstring is the tool description the model actually reads, and the Args: block becomes the parameter descriptions. This is one of the nicer things about the SDK — there’s no separate schema to keep in sync — but it also means a lazy docstring produces a badly-called tool.
Serply’s search endpoint takes the Google-style query string as a path segment, so it’s /v1/search/q=..., not a ?q= parameter. Organic results arrive under results with title, link, description, and position.
A reader tool
@function_tool
def read_page(url: str) -> str:
"""Read the full text of a web page, converted to markdown.
Use this after web_search when a snippet doesn't contain enough detail.
Args:
url: The full URL of the page to read.
"""
resp = requests.post(
f"{BASE}/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
if resp.status_code == 429:
return "Rate limited while fetching that page."
resp.raise_for_status()
return resp.text[:15000]
Markdown mode returns the text directly in the body, so .text is correct. "response_type": "full" returns JSON with raw HTML under data — useful when you need the markup, wasteful when you’re feeding a model.
Splitting search from reading
A single agent holding both tools tends to over-read: it fetches five pages when the snippets already answered the question. Splitting the work across two agents with a handoff makes the boundary explicit.
reader = Agent(
name="Reader",
instructions=(
"You read web pages in depth. You'll be handed a question and a set of "
"candidate URLs. Read the most relevant two or three with read_page, then "
"answer with direct quotes and cite each URL."
),
tools=[read_page],
)
scout = Agent(
name="Scout",
instructions=(
"You are a research scout. Call web_search to find candidates. "
"If the snippets fully answer the question, answer directly and cite links. "
"If they don't, hand off to the Reader with the question and the best URLs."
),
tools=[web_search],
handoffs=[reader],
)
result = Runner.run_sync(scout, "What changed in Google's SERP layout this year?")
print(result.final_output)
The instruction “if the snippets fully answer the question, answer directly” is doing real cost control. Handoffs are cheap to describe and expensive to run.
Structured output
Force citations structurally rather than asking for them:
from pydantic import BaseModel
class Citation(BaseModel):
claim: str
url: str
class Research(BaseModel):
answer: str
citations: list[Citation]
scout = Agent(
name="Scout",
instructions="Research the question. Every claim needs a citation.",
tools=[web_search],
handoffs=[reader],
output_type=Research,
)
result = Runner.run_sync(scout, "Which SERP APIs support mobile device emulation?")
for c in result.final_output.citations:
print(f"- {c.claim}\n {c.url}")
A model can decline to follow a prompt instruction about citations. It cannot emit a Citation without a url field.
A guardrail worth adding
Search agents drift toward answering from memory when a search returns thin results. An output guardrail catches it:
from agents import output_guardrail, GuardrailFunctionOutput
@output_guardrail
async def must_cite(ctx, agent, output: Research) -> GuardrailFunctionOutput:
uncited = [c for c in output.citations if not c.url.startswith("http")]
return GuardrailFunctionOutput(
output_info={"uncited": len(uncited)},
tripwire_triggered=bool(uncited) or not output.citations,
)
Tripping on an empty citation list is the important half. An answer with zero citations from a research agent is nearly always the model falling back on training data, and that’s precisely the failure this whole setup exists to prevent.