Local models have one obvious weakness. They only know what was in their training data. Ask a local Qwen or Llama about a release from last week and you get either a polite refusal or a confident answer that is wrong.
The usual fix is to bolt on a search API yourself. Sign up for a search provider, wire up the HTTP calls, parse the HTML, strip the ads, chunk the text, and hand it to the model. It works, but it is a lot of plumbing for something every agent needs.
Ollama now ships that plumbing. There is a built in web search API and a page fetch API, both usable as tools that a model can call on its own. In this tutorial we build a research agent that decides what to search for, reads the pages it finds, and answers with current information. It runs against a small local model.
What you get
Two endpoints, both hosted by Ollama:
POST https://ollama.com/api/web_searchtakes a query and returns a list of results, each with a title, a URL and a chunk of the page content.POST https://ollama.com/api/web_fetchtakes a single URL and returns the page title, its main content and the links it found.
The important detail is the second field on each search result. You get content, not just a snippet and a link. That means a single search call often gives the model enough to answer without a second round trip.
Both are exposed as first class functions in the Python and JavaScript clients, and both can be passed straight to a model as tools.
Step 1: Get an API key
These endpoints run on Ollama’s servers, so they need an account and a key even though your model is local.
Create a free account, then generate a key at ollama.com/settings/keys and export it:
export OLLAMA_API_KEY=your_api_key_here
Add that line to your shell profile so it survives a new terminal. The client libraries read it automatically, so you will not see it in the code below.
Step 2: Try the raw endpoint
Before wiring anything into a model, see what the API returns:
curl https://ollama.com/api/web_search \
--header "Authorization: Bearer $OLLAMA_API_KEY" \
-d '{
"query": "what is ollama?"
}'
The response has a results array. Each entry looks like this:
{
"results": [
{
"title": "Ollama",
"url": "https://ollama.com/",
"content": "Cloud models are now available..."
}
]
}
Two parameters matter:
queryis required and is the search string.max_resultsis optional. It defaults to 5 and caps at 10.
Keep max_results low when you are feeding results into a small model. Ten results with full content can easily blow past an 8K context window.
Step 3: Search from Python
pip install ollama
import ollama
response = ollama.web_search("what is new in Ollama")
for result in response['results']:
print(result['title'])
print(result['url'])
print(result['content'][:200])
print('---')
Fetching a specific page is just as short:
from ollama import web_fetch
result = web_fetch('https://ollama.com')
print(result)
The JavaScript client mirrors this:
import { Ollama } from 'ollama';
const client = new Ollama();
const results = await client.webSearch({ query: 'what is ollama?' });
console.log(JSON.stringify(results, null, 2));
const page = await client.webFetch({ url: 'https://ollama.com' });
console.log(page.title);
Step 4: Let the model drive
Calling search yourself is useful, but the real win is handing both functions to the model as tools and letting it decide when to use them.
Pull a model that supports tool calling. A 4 billion parameter Qwen is plenty and runs comfortably on a laptop:
ollama pull qwen3:4b
Now the agent loop:
from ollama import chat, web_fetch, web_search
available_tools = {'web_search': web_search, 'web_fetch': web_fetch}
messages = [{'role': 'user', 'content': "what is ollama's new engine"}]
while True:
response = chat(
model='qwen3:4b',
messages=messages,
tools=[web_search, web_fetch],
think=True
)
if response.message.thinking:
print('Thinking: ', response.message.thinking)
if response.message.content:
print('Content: ', response.message.content)
messages.append(response.message)
if response.message.tool_calls:
for tool_call in response.message.tool_calls:
function_to_call = available_tools.get(tool_call.function.name)
if function_to_call:
args = tool_call.function.arguments
result = function_to_call(**args)
messages.append({
'role': 'tool',
'content': str(result)[:8000],
'tool_name': tool_call.function.name
})
else:
break
Run it and watch the trace. The model reasons about the question, issues a search, reads the titles and content it got back, sometimes decides to fetch one page in full, and then writes an answer. All of the searching happens because the model asked for it, not because you scripted it.
Reading the loop
There are four pieces worth understanding, because you will reuse this shape for any tool using agent.
The tools list. Passing tools=[web_search, web_fetch] works because the Python client reads the function signatures and docstrings and builds the JSON schema for you. You do not hand write a tool definition.
The message history. Every model response gets appended, and every tool result gets appended with role: 'tool'. The model needs to see its own tool call and the result that came back, or it will just call the same thing again.
The truncation. That [:8000] is doing real work. A fetched page can be enormous. Without a cap you will overflow the context window and the model will either error or silently drop the start of the conversation. Tune the number to your model’s context.
The exit condition. The loop ends when the model replies with no tool calls, which is its way of saying it has enough to answer. This is the standard agent pattern and it is why the loop is a while True rather than a fixed number of steps.
Making it safer for production
The loop above is a good demo. Before you put it anywhere near real users, add three guards.
First, a step limit. A model that misreads a result can loop forever, and every iteration costs you a search call:
MAX_STEPS = 8
for step in range(MAX_STEPS):
response = chat(model='qwen3:4b', messages=messages,
tools=[web_search, web_fetch], think=True)
messages.append(response.message)
if not response.message.tool_calls:
break
for tool_call in response.message.tool_calls:
fn = available_tools.get(tool_call.function.name)
if not fn:
continue
try:
result = fn(**tool_call.function.arguments)
except Exception as exc:
result = f'Tool failed: {exc}'
messages.append({
'role': 'tool',
'content': str(result)[:8000],
'tool_name': tool_call.function.name
})
else:
print('Hit the step limit without a final answer.')
Second, error handling around the tool call, as above. A fetch can time out or hit a page that blocks robots. Feeding the error back as a tool result lets the model recover and try a different source, which is much better than crashing.
Third, and this is the one people skip: treat fetched page content as untrusted input. A web page can contain text that looks like an instruction to your model. If your agent has other tools, such as writing files or sending messages, a hostile page can try to talk it into using them. Keep the research agent’s tool set narrow, and do not give a model that reads arbitrary web pages the ability to take actions you would not want a stranger to trigger.
A useful variant: cite your sources
One small change makes the output far more trustworthy. Track the URLs the model actually looked at and print them at the end:
sources = []
def tracked_search(**kwargs):
result = web_search(**kwargs)
for item in result.get('results', []):
sources.append(item['url'])
return result
def tracked_fetch(**kwargs):
sources.append(kwargs.get('url'))
return web_fetch(**kwargs)
available_tools = {'web_search': tracked_search, 'web_fetch': tracked_fetch}
# ... run the loop ...
print('\nSources:')
for url in dict.fromkeys(sources):
print(' -', url)
Now anyone reading the answer can check where it came from. For a research tool this matters more than the phrasing of the answer itself.
When to use this and when not to
The web search API is a good fit when your model needs facts that postdate its training, when you want an agent to look things up on its own, or when you are prototyping and do not want to sign up for a separate search provider.
It is a poor fit when you need to search your own private documents. That is a retrieval problem over your own index, not a web search, and you want a vector store for it.
It is also worth remembering that the search itself runs on Ollama’s servers, so your queries leave your machine even when the model does not. If the query text itself is sensitive, that is a real consideration.
Picking a model that can actually use tools
The agent loop above is the easy part. Model choice is what decides whether it works.
Tool calling asks more of a model than chat does. It has to emit a well formed function call, pick sensible arguments, read a wall of returned text, and decide whether it has enough to stop. Plenty of models that chat well fail at one of those steps.
Three failure modes you will recognise:
- It never searches. The model answers from memory and ignores the tools entirely. Usually a model that was not trained for tool use, or a prompt that did not make clear the information is recent.
- It searches forever. Every result prompts another search and the loop never converges. This is what the step limit is for.
- It searches once and ignores the result. The content came back but the model answered from its own priors anyway. Often a context window problem, because the tool result got pushed out.
Before building anything on top of a model, test it in isolation:
from ollama import chat, web_search
CANDIDATES = ['qwen3:4b', 'qwen3:8b', 'llama3.2']
QUESTION = 'What version of Ollama was released most recently?'
for model in CANDIDATES:
response = chat(
model=model,
messages=[{'role': 'user', 'content': QUESTION}],
tools=[web_search]
)
called = bool(response.message.tool_calls)
query = response.message.tool_calls[0].function.arguments if called else None
print(f'{model:14} searched={called} query={query}')
You are checking two things. Did it reach for the tool at all, and was the query it wrote a sensible one. A model that searches for the user’s exact words rather than a good query will give you poor results no matter how good the search API is.
Cache results so you stop paying twice
During development you will run the same query dozens of times. Every one of those is a billed search call. A small cache on disk fixes it in about ten lines:
import hashlib
import json
import pathlib
from ollama import web_search
CACHE = pathlib.Path('.search_cache')
CACHE.mkdir(exist_ok=True)
def cached_search(query, max_results=5):
key = hashlib.sha256(f'{query}|{max_results}'.encode()).hexdigest()[:16]
path = CACHE / f'{key}.json'
if path.exists():
return json.loads(path.read_text())
result = web_search(query, max_results=max_results)
path.write_text(json.dumps(result))
return result
Swap cached_search into your tools dictionary while you are iterating on prompts. Add the cache directory to .gitignore, and delete it when you want fresh results.
Do not ship this to production as written. A cache with no expiry defeats the point of a search tool, which is getting current information. If you want caching in production, add a timestamp and treat anything older than an hour as a miss.
Wrapping up
A local model with search is a different animal from a local model on its own. It stops guessing about recent things and starts looking them up.
The whole setup is one API key, one pip install, and about thirty lines of loop. Start with the demo agent, add the step limit and the error handling, and you have something you can actually leave running.