Building a Search Toolkit for an Agno Agent

Agno's Toolkit class groups related functions into one unit. Search, news, and page reading belong together — here's how they fit.

Profile picture of Serply
Serply
An agent toolkit grouping search, news, and read functions

Agno’s design puts related tools into a Toolkit subclass rather than scattering loose functions. For search that’s the right grain: search, news, and read-page share configuration, share a rate-limit budget, and are useless individually.

The toolkit

import os
import requests
from urllib.parse import quote_plus
from agno.tools import Toolkit


class SerplyTools(Toolkit):
    def __init__(
        self,
        api_key: str | None = None,
        location: str = "US",
        max_results: int = 10,
        max_chars: int = 12_000,
    ):
        super().__init__(name="serply_tools")

        self.api_key = api_key or os.environ["SERPLY_API_KEY"]
        self.base = "https://api.serply.io/v1"
        self.location = location
        self.max_results = max_results
        self.max_chars = max_chars

        self.register(self.web_search)
        self.register(self.news_search)
        self.register(self.read_page)

    def _headers(self, **extra) -> dict:
        return {"X-Api-Key": self.api_key, "X-Proxy-Location": self.location, **extra}

Configuration on the instance rather than in each function is the reason to use a Toolkit at all. Change location once and every tool in the group searches from the new country.

    def web_search(self, query: str, num: int | None = None) -> str:
        """Search the live web via Google.

        Use this for current events, specific facts, product details, or
        anything you should not answer from memory. Returns ranked results
        with title, URL, and a short snippet. Snippets are 1-2 sentences —
        call read_page for full content.

        Args:
            query: Keyword-style search query, not a full sentence.
            num: How many results to return. Defaults to the toolkit setting.
        """
        n = num or self.max_results
        try:
            resp = requests.get(
                f"{self.base}/search/q={quote_plus(query)}&num={n}",
                headers=self._headers(),
                timeout=30,
            )
        except requests.RequestException as e:
            return f"Search request failed: {e}. Try again once."

        if resp.status_code == 429:
            return ("Rate limited. Wait a few seconds, then issue ONE more "
                    "specific query rather than several.")
        if not resp.ok:
            return f"Search failed with HTTP {resp.status_code}."

        results = resp.json().get("results", [])
        if not results:
            return f'No results for "{query}". Try broader or different keywords.'

        lines = [
            f"{i}. {r.get('title', '')}\n   {r.get('link', '')}\n   {r.get('description', '')}"
            for i, r in enumerate(results, 1)
        ]
        return "\n\n".join(lines)

Agno reads the docstring to build the tool description the model sees, so the docstring is prompt engineering, not documentation. Everything in it is load-bearing: when to use the tool, what shape comes back, and the pointer to read_page when snippets aren’t enough.

Returning error strings rather than raising is deliberate. An exception ends the run; a string telling the model what happened lets it recover.

The query goes in the path (/search/q=...). A ?q= querystring will not work against this API.

News

    def news_search(self, query: str) -> str:
        """Search recent news articles.

        Use when recency matters — company announcements, events, ongoing
        stories. Returns headline, source, publication date, and summary.
        For general factual questions use web_search instead.

        Args:
            query: Topic, company, or person to find coverage of.
        """
        try:
            resp = requests.get(
                f"{self.base}/news/q={quote_plus(query)}",
                headers=self._headers(),
                timeout=30,
            )
            resp.raise_for_status()
        except requests.RequestException as e:
            return f"News search failed: {e}"

        entries = resp.json().get("feed", {}).get("entries", [])
        if not entries:
            return f'No recent coverage of "{query}".'

        return "\n\n".join(
            f"{e.get('title', '')}\n"
            f"  {e.get('source', 'unknown source')}{e.get('published', 'no date')}\n"
            f"  {e.get('link', '')}\n"
            f"  {e.get('summary', '')}"
            for e in entries
        )

The news endpoint nests articles under feed.entries, not results — a different shape from every other search endpoint, and the most common thing to get wrong here.

Putting source and date on their own line is what lets the model reason about recency. Buried inline, it tends to treat a six-month-old article as current.

Reading pages

    def read_page(self, url: str) -> str:
        """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 than a search — read the 2-3 most promising
        results, not everything.

        Args:
            url: Absolute URL, normally taken from a search result.
        """
        try:
            resp = requests.post(
                f"{self.base}/request",
                headers=self._headers(**{"Content-Type": "application/json"}),
                json={"url": url, "response_type": "markdown"},
                timeout=60,
            )
            resp.raise_for_status()
        except requests.RequestException as e:
            return f"Could not fetch {url}: {e}. Try a different source."

        text = resp.text
        if len(text) > self.max_chars:
            return (text[: self.max_chars] +
                    f"\n\n[Truncated at {self.max_chars} of {len(text)} characters.]")
        return text

resp.text, not resp.json(). Markdown mode returns the content as the raw response body.

The truncation marker is worth the characters. Without it the model summarises a third of a document as though it had the whole thing, and there’s no way to tell from the output that it happened.

The agent

from agno.agent import Agent
from agno.models.anthropic import Claude

agent = Agent(
    model=Claude(id="claude-sonnet-4-5"),
    tools=[SerplyTools(location="US", max_results=10)],
    instructions=[
        "Search before answering anything factual, current, or specific.",
        "Read the 2-3 most promising results in full before concluding — "
        "snippets are not sufficient evidence.",
        "Cite the URL for every factual claim.",
        "If the sources do not answer the question, say so plainly. Do not "
        "fill gaps from memory.",
        "Use news_search for events and announcements, web_search for "
        "everything else.",
    ],
    markdown=True,
    show_tool_calls=True,
)

agent.print_response(
    "What changed in the EU AI Act timeline this year?", stream=True
)

show_tool_calls=True during development is not optional. Most “the agent gave a bad answer” bugs turn out to be “the agent searched for something odd” or “the agent never called the tool,” and both are invisible until you can see the calls.

Scoping the toolkit per agent

Because configuration lives on the instance, specialised agents get specialised toolkits without new code:

uk_analyst = Agent(
    model=Claude(id="claude-sonnet-4-5"),
    tools=[SerplyTools(location="GB", max_results=20, max_chars=25_000)],
    instructions=["You research the UK market. Search from a UK perspective."],
)

quick_lookup = Agent(
    model=Claude(id="claude-haiku-4-5-20251001"),
    tools=[SerplyTools(max_results=3, max_chars=3_000)],
    instructions=["Answer in one or two sentences with a citation."],
)

The second one is the pattern worth stealing. A cheap model with a deliberately small result budget handles the bulk of simple lookups, and you route only the hard questions to the expensive agent with the deep budget.