NestDaddy
Ollama

Give a local LLM live web search and news with Ollama and Python

Local models only know their training data. This guide gives an Ollama model three tools (web search, news search and page reading) in about 100 lines of Python, with a tool-calling loop you can reuse in any agent.

NestDaddy team · · 6 min read

A model running on your own machine is private, cheap and fast. It also stops learning the day its training data was frozen. Ask it about this week's news, a library released last month or today's price of anything, and it will guess.

The fix is tool calling. You give the model a few functions it is allowed to call, it decides when it needs fresh information, your code runs the function, and the result goes back into the conversation. Ollama supports this for models such as Qwen 3, Llama 3.1 and Mistral.

In this guide you will build a small research assistant that runs on Ollama and can:

  1. Search the web for facts, documentation and products.
  2. Search recent news by key words, number of days and country.
  3. Read a page in full when a snippet is not enough.

The whole thing is one Python file of about 100 lines.

What you need

  • Ollama running locally, with a model that supports tools. This guide uses qwen3:8b:

bash ollama pull qwen3:8b

  • Python 3.9 or newer and two packages:

bash pip install ollama requests

  • A search API key. The examples use the NestDaddy API, which returns plain JSON from its own web index and a news collection from 45+ countries. A free 7-day trial key works.
export NESTDADDY_KEY="your_key"

Step 1: write the tools as normal Python functions

Since version 0.4, the Ollama Python library can turn a plain function into a tool definition. It reads the function name, the type hints and the Google-style docstring, so the docstring is what the model sees. Write it for the model: say when to use the tool, not only what it does.

import json
import os

import ollama
import requests

API = "https://nestdaddy.com/api/v1"
KEY = os.environ["NESTDADDY_KEY"]
MODEL = os.environ.get("OLLAMA_MODEL", "qwen3:8b")


def _get(path, **params):
    r = requests.get(f"{API}/{path}", headers={"X-API-Key": KEY},
                     params={k: v for k, v in params.items() if v is not None},
                     timeout=30)
    r.raise_for_status()
    return r.json()


def web_search(query: str, limit: int = 5) -> str:
    """Search the web. Use it for facts, documentation, products and anything
    the model may not know.

    Args:
        query: Short key words, like a search box query.
        limit: Number of results, 1 to 10.

    Returns:
        JSON list of results with title, url and snippet.
    """
    data = _get("search", q=query, limit=max(1, min(int(limit), 10)))
    return json.dumps([
        {"title": x["title"], "url": x["url"], "snippet": x.get("snippet", "")}
        for x in data.get("results", [])
    ])


def news_search(query: str, days: int = 7, country: str = "") -> str:
    """Search recent news articles. Use it for events, prices, launches and
    anything that happened recently.

    Args:
        query: Key words, for example a company, person or topic.
        days: How many days back to search, 1 to 120.
        country: Optional two-letter country code such as US, GB, IN or SG.

    Returns:
        JSON list of articles with date, title, source, url and description.
    """
    data = _get("news/search", q=query, days=max(1, min(int(days), 120)),
                country=country or None, limit=8)
    return json.dumps([
        {"date": a.get("published_timestamp"), "title": a["title"],
         "source": a.get("source_domain"), "url": a["url"],
         "description": a.get("description", "")}
        for a in data.get("results", [])
    ])


def read_page(url: str) -> str:
    """Read the main text of a web page, without menus or ads. Use it after a
    search when a snippet is not enough.

    Args:
        url: The full URL of the page.

    Returns:
        The page text, cut to 6,000 characters.
    """
    data = _get("extract", url=url)
    return (data.get("text") or "")[:6000]

Three details matter more than they look:

  • Return small, clean JSON. Only the fields the model needs. Every extra field costs context and makes small models drift.
  • Clamp the arguments. Models sometimes ask for 50 results or 999 days. The min and max calls keep requests sane.
  • Cut long pages. 6,000 characters is enough for most answers and fits comfortably in an 8B model's context next to the conversation.

Step 2: the tool-calling loop

The model answers in one of two ways: with text (it is done) or with one or more tool calls (it wants data). The loop runs the tools, appends each result as a tool message, and asks again, until the model answers with text or a step limit is reached.

TOOLS = {f.__name__: f for f in (web_search, news_search, read_page)}

SYSTEM = (
    "You are a research assistant with live web access. "
    "Use news_search for recent events and web_search for everything else. "
    "Read the one or two best pages in full with read_page before answering "
    "a detailed question. Answer briefly and list the URLs you used."
)


def ask(question: str, max_steps: int = 6) -> str:
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": question}]
    for _ in range(max_steps):
        response = ollama.chat(model=MODEL, messages=messages,
                               tools=list(TOOLS.values()))
        messages.append(response.message)
        calls = response.message.tool_calls or []
        if not calls:
            return response.message.content
        for call in calls:
            fn = TOOLS.get(call.function.name)
            try:
                output = fn(**call.function.arguments) if fn else "unknown tool"
            except Exception as exc:  # give the error back to the model
                output = f"tool error: {exc}"
            messages.append({"role": "tool", "content": output,
                             "tool_name": call.function.name})
    return "Stopped after too many tool calls."


if __name__ == "__main__":
    import sys
    print(ask(" ".join(sys.argv[1:]) or "What happened with OpenAI this week?"))

Run it:

python web_agent.py "What changed in the latest Python release?"
python web_agent.py "Top technology news in the UK in the last 3 days"

A typical run for a news question goes: news_search for the topic, read_page on the most relevant article, then a short answer with links.

Why the loop is written this way

  • Errors go back to the model instead of crashing the script. A 404 page or a timeout becomes tool error: ..., and the model usually tries another source.
  • Unknown tool names are answered, not ignored. Small models sometimes invent a tool. Telling them it does not exist gets them back on track.
  • max_steps stops loops. Without a limit, a confused model can search forever.
  • The system prompt routes the tools. "News for recent events, web for everything else" is the single instruction that improves answers most, because news indexes and web indexes are good at different things.

Ollama's own web search, or a separate API?

Ollama also offers a hosted web search API, used with a free Ollama account and an API key. If you only need general web results for a local model, it is a reasonable place to start.

A separate search API is worth it when you need more than general results:

  • News with filters: key words plus a date window and a country, so "last 3 days in India" means exactly that.
  • Full page text from any URL, not only the pages a search returned.
  • The same key everywhere: the same data from a cron job, a backend, a no-code tool or an MCP client such as Claude or Cursor.

Both approaches use the same tool-calling loop above. Only the body of the tool functions changes.

Make it better

  • Answer with dates. Add "Always mention the date of each news item" to the system prompt. Local models are bad at knowing today's date, so you can also add it: f"Today is {date.today():%d %B %Y}."
  • Cache results. Put @functools.lru_cache(maxsize=256) on _get so repeated questions do not repeat API calls.
  • Use a bigger model for hard questions. 8B models pick tools well but summarise long pages less reliably. A 14B or 32B model, or a larger context window (options={"num_ctx": 16384}), helps with read_page.
  • Stream the final answer with stream=True once no more tool calls are coming.

Get the code and a key

The complete file is the two code blocks above, in this order. More examples in Python, JavaScript and curl are on GitHub: nestdaddy/nestdaddy-examples.

NestDaddy's All-in-One plan covers web search, news, page reading, research papers and the MCP server with one key, from $9.99 a month for 10,000 calls. There is a free 7-day trial with 5,000 calls and no card: get a key.

More guides