Adding a Custom Search Tool to a Google ADK Agent
ADK gives agents a built-in search, but it's tied to one provider. A plain Python function tool gets you control over the whole retrieval layer.


Google’s Agent Development Kit takes an appealing shortcut on tools: a plain Python function with type hints and a docstring becomes a tool. No decorator, no schema class. The docstring is the description the model sees, and the signature is the schema.
That’s convenient right up until you need retrieval you control — a specific engine, a specific country, your own result formatting, your own rate-limit handling. Then you write the function yourself, and it’s about thirty lines.
The search function
import os
import requests
from urllib.parse import quote_plus
API_KEY = os.environ["SERPLY_API_KEY"]
BASE = "https://api.serply.io/v1"
def web_search(query: str, num_results: int = 10, country: str = "US") -> dict:
"""Search the live web and return ranked results.
Use this for current events, specific facts, product details, or anything
you are not confident answering from memory. Returns titles, URLs, and
short snippets. Snippets are one or two sentences — call read_page on a
URL when you need the full content.
Args:
query: Keyword-style search query. Not a full sentence.
num_results: How many results to return, between 1 and 100.
country: Two-letter country code to search from (US, GB, DE, JP, AU...).
Returns:
A dict with 'status' and either 'results' or 'error_message'.
"""
try:
resp = requests.get(
f"{BASE}/search/q={quote_plus(query)}&num={num_results}",
headers={"X-Api-Key": API_KEY, "X-Proxy-Location": country.upper()},
timeout=30,
)
except requests.RequestException as e:
return {"status": "error", "error_message": f"Request failed: {e}"}
if resp.status_code == 429:
return {
"status": "error",
"error_message": ("Rate limited. Wait a few seconds, then issue one "
"more specific query rather than several broad ones."),
}
if not resp.ok:
return {"status": "error", "error_message": f"HTTP {resp.status_code}"}
results = resp.json().get("results", [])
if not results:
return {
"status": "success",
"results": [],
"note": f'No results for "{query}". Try broader or different keywords.',
}
return {
"status": "success",
"results": [
{
"title": r.get("title", ""),
"url": r.get("link", ""),
"snippet": r.get("description", ""),
"position": r.get("position"),
}
for r in results
],
}
Three ADK-specific things here.
The docstring format is the contract. ADK parses it into the tool description and per-argument descriptions, so the Args: block genuinely affects how the model calls the function. A vague query: the query produces worse queries than Keyword-style search query. Not a full sentence.
Returning a dict with a status key is the ADK convention, and it’s a good one — the model can distinguish a failure from an empty result, which are different situations requiring different next moves.
Errors return as values, not exceptions. A raised exception ends the invocation; a returned error message lets the agent adapt.
Note the URL shape: the query goes in the path as /search/q=..., not as a ?q= querystring.
News, with its own shape
def news_search(query: str) -> dict:
"""Search recent news articles about a topic, company, or person.
Use when recency matters — announcements, events, ongoing stories. Returns
headlines with source and publication date. For general factual questions,
use web_search instead.
Args:
query: Topic, company, or person to find recent coverage of.
Returns:
A dict with 'status' and either 'articles' or 'error_message'.
"""
try:
resp = requests.get(
f"{BASE}/news/q={quote_plus(query)}",
headers={"X-Api-Key": API_KEY},
timeout=30,
)
resp.raise_for_status()
except requests.RequestException as e:
return {"status": "error", "error_message": str(e)}
entries = resp.json().get("feed", {}).get("entries", [])
return {
"status": "success",
"articles": [
{
"title": e.get("title", ""),
"url": e.get("link", ""),
"summary": e.get("summary", ""),
"published": e.get("published"),
"source": e.get("source"),
}
for e in entries
],
}
The news endpoint nests its articles under feed.entries rather than results. Flattening that into an articles list here means the agent sees one consistent structure across both tools.
Reading a page
def read_page(url: str, max_chars: int = 15000) -> dict:
"""Fetch the full text of a web page as markdown.
Use after web_search when a snippet is not enough to answer confidently.
This is more expensive and slower than a search — read the two or three
most promising results, not every result.
Args:
url: Absolute URL, normally taken from a previous search result.
max_chars: Truncation limit for the returned text.
Returns:
A dict with 'status' and either 'text' (plus 'truncated') or
'error_message'.
"""
try:
resp = requests.post(
f"{BASE}/request",
headers={"X-Api-Key": API_KEY, "Content-Type": "application/json"},
json={"url": url, "response_type": "markdown"},
timeout=60,
)
resp.raise_for_status()
except requests.RequestException as e:
return {
"status": "error",
"error_message": f"Could not fetch {url}: {e}. Try another source.",
}
full = resp.text
return {
"status": "success",
"text": full[:max_chars],
"truncated": len(full) > max_chars,
"total_chars": len(full),
}
resp.text, not resp.json() — markdown mode returns the content as the raw response body. If you’d rather have JSON everywhere, "response_type": "full" returns HTML wrapped as {"data": "..."}.
Exposing truncated as a field rather than silently cutting lets the agent recognise it only saw part of a document, which is the difference between “the page doesn’t mention it” and “I didn’t get that far.”
The agent
from google.adk.agents import Agent
root_agent = Agent(
name="researcher",
model="gemini-2.0-flash",
description="Answers questions using live web sources.",
instruction="""You research questions using live web search.
Search before answering anything factual, current, or specific. Do not answer
those from memory.
After searching, read the two or three most promising results in full before
concluding. Snippets are not sufficient evidence for a factual claim.
Cite the URL for every fact you state. If sources disagree, present both. If
the sources do not answer the question, say so plainly rather than filling the
gap from background knowledge.
Use news_search for events and announcements, web_search for everything else.""",
tools=[web_search, news_search, read_page],
)
ADK’s instruction and the per-tool docstrings do different jobs. The instruction sets policy — when to search, what counts as evidence, what to do when there isn’t any. The docstrings set mechanics — what each tool does and what its arguments mean. Trying to put tool mechanics in the instruction, or policy in the docstrings, works less well than splitting them this way.
Trying it
adk web
The dev UI’s trace view is where the debugging happens. Most “the agent answered badly” reports resolve to one of two things visible immediately in the trace: it searched for something odd, or it got zero results and answered anyway. Both are fixed in the docstring or the instruction, not in the model.