Large Language Models (LLMs) are remarkably capable, but they suffer from two fundamental flaws: knowledge cutoffs and hallucinations. Ask an offline model about a breaking news event, a shifting stock price, or a new library release that dropped this morning, and it will either confidently fabricate a wrong answer or admit it has no idea. To build production-grade AI tools, your models need eyes and ears on the live web; your models need to be in the know of happenings as they break.
While static Retrieval-Augmented Generation (RAG) works well for searching internal vector databases, it fails the moment an accurate answer requires a real-time Google search. That is where AI search agents come in. An autonomous search agent turns an LLM into a reasoning engine that can choose when to browse the web, pull down search engine results pages (SERPs), synthesize real-time data, and deliver accurate, fact-checked answers.
In this article, you will build a production-ready AI search agent from scratch using Python, OpenAI's tool-calling framework, and SearchApi.io as the live web data pipeline.
Why traditional web scraping fails for AI agents
When developers realize their AI agent needs Google data, the first instinct is usually to reach for raw browser automation tools like Playwright, Puppeteer, or BeautifulSoup. You write code to open a headless browser, navigate to Google, type a query, and scrape the HTML.
In a local testing environment, this approach works for about three requests. In production, it breaks immediately.
Building your own search engine scraper means constantly fighting multi-billion-dollar anti-bot security teams. Your script will quickly run into:
- IP blocks and CAPTCHAs: Search engines rapidly flag and block data center IP addresses. Rotating your own proxy pool is expensive and time-consuming to maintain.
-
Fragile DOM structures: Search engines constantly shift their HTML layout and CSS classes. A script targeting
div.gtonight will silently return empty data by tomorrow morning. - Compute overhead: Spinning up heavy browser environments (like headless Chromium) consumes significant server memory and CPU cycles, adding cost and latency to every query.
To keep AI agents fast and reliable, you need an abstraction layer that handles proxy rotation, bypasses CAPTCHAs, and returns structured data instantly. By offloading this infrastructure to SearchApi.io, you retrieve clean, pre-parsed JSON payloads using a lightweight HTTP request - at a fraction of the compute overhead.
System architecture
Before writing any code, it helps to understand how an autonomous tool-calling loop operates. This system does not blindly search the web on every turn. Instead, it follows a four-step conversation loop:
- The intent check: The user asks a question. The LLM evaluates whether it can answer using its internal knowledge or whether it needs live web data.
-
The tool call trigger: If it needs live data, the LLM stops generating text and outputs a structured request to invoke the
search_webtool. - The infrastructure layer: The Python script intercepts this request, queries SearchApi's Google engine, and returns the top organic search results as structured JSON.
- The synthesis: The text snippets are injected back into the LLM's context window. The model reads the live web data and generates a grounded, hallucination-free response.
This architecture is efficient because the LLM acts as the decision-maker. It avoids unnecessary API calls for questions it can already answer accurately from its training data.
Prerequisites and project setup
To follow along with this tutorial, you will need three things:
- Python 3.8+ installed on your machine.
- An OpenAI API key to power the model's reasoning loop.
- A SearchApi API key to handle live web access. You can sign up at SearchApi.io and get 100 free searches right out of the box - no credit card required.
Start by initializing the project folder and installing the required dependencies:
# Create and move into your project folder
mkdir ai-search-agent && cd ai-search-agent
# Install dependencies
pip install requests openai python-dotenv
Next, create a .env file in the root of your project directory to keep your API tokens secure:
OPENAI_API_KEY="your_actual_openai_api_key_here"
SEARCHAPI_API_KEY="your_actual_searchapi_api_key_here"
With the project environment ready, here is an overview of the full project structure you will build:
Building a Real-Time AI Search Agent/
├── ai-search-agent/ <- Python CLI Implementation
│ ├── search_helper.py <- SearchApi wrapper function (Step 1)
│ ├── agent.py <- OpenAI tool schema and ask_agent() (Step 2)
│ ├── run.py <- Orchestrator loop (Step 3)
│ ├── .env.example <- Template for environment variables
│ └── requirements.txt <- pip dependencies
└── realtime-search/ <- Production Next.js 14 Application
├── app/
│ ├── api/
│ │ ├── search/ <- Server-side SearchApi route handler
│ │ └── synthesize/ <- Real-time OpenAI SSE streaming handler
│ ├── globals.css <- Design system tokens & glassmorphism
│ ├── layout.tsx <- Root layout & SSR theme script
│ └── page.tsx <- Agent dashboard container
├── components/ <- React UI components
├── hooks/ <- Pipeline orchestration & history hooks
├── lib/ <- Server utilities & TypeScript definitions
└── README.md <- Next.js application documentation
Step 1: Writing the search API helper function
The first module is a search_web() function that sends a query to SearchApi's Google Search engine and returns a compressed block of text containing the titles, snippets, and source links from the top organic results.
The key design decision here is to extract only three fields - title, snippet, and link - from SearchApi's organic_results array. This keeps the payload lightweight and prevents the LLM from wasting tokens on irrelevant metadata like ad blocks, knowledge panels, or navigation breadcrumbs.
Create a file called search_helper.py:
import os
import requests
from dotenv import load_dotenv
# Load API keys from environment variables
load_dotenv()
SEARCHAPI_KEY = os.getenv("SEARCHAPI_API_KEY")
def search_web(query: str, location: str = "United States") -> str:
"""
Queries the SearchApi.io Google Search engine and returns a compressed
string containing the titles, snippets, and links of organic results.
"""
if not SEARCHAPI_KEY:
return "Error: SearchApi API key missing from environment variables."
url = "https://www.searchapi.io/api/v1/search"
# Configure payload parameters according to the SearchApi documentation
params = {
"engine": "google",
"q": query,
"location": location,
"api_key": SEARCHAPI_KEY
}
try:
response = requests.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# Extract organic search results
organic_results = data.get("organic_results", [])
if not organic_results:
return f"No relevant web search results found for: '{query}'"
# Parse and stringify the results into a compact context block for the LLM
formatted_results = []
for index, result in enumerate(organic_results[:5], 1): # Limit to top 5 results
title = result.get("title", "No Title")
snippet = result.get("snippet", "No Snippet Available")
link = result.get("link", "")
formatted_results.append(f"[{index}] {title}\nSnippet: {snippet}\nSource: {link}\n---")
return "\n".join(formatted_results)
except requests.exceptions.RequestException as e:
return f"An infrastructure error occurred while querying the search API: {str(e)}"
# Quick standalone test execution
if __name__ == "__main__":
test_query = "Who won the men's 100m sprint in the 2024 Paris Olympics?"
print(f"Testing SearchApi wrapper with query: '{test_query}'...\n")
print(search_web(test_query))
The organic_results[:5] slice is intentional. Feeding more than five results into the LLM context rarely improves answer quality and meaningfully increases token consumption and cost.
Step 2: Defining the LLM agent framework (tool calling)
With the data pipeline ready, you need to teach the LLM when and how to use it. This is done through OpenAI's tool calling (formerly called function calling) feature, which lets you define structured schemas that the model can invoke when it determines external data is required.
Create a file, and name it agent.py:
import os
import json
from openai import OpenAI
from search_helper import search_web
from dotenv import load_dotenv
load_dotenv()
# Initialize the OpenAI client
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
# Define the SearchApi tool schema for the model
search_tool_definition = {
"type": "function",
"function": {
"name": "search_web",
"description": (
"Call this tool whenever you need up-to-date information, news, "
"current events, or web data that occurred after your knowledge cutoff date."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"The optimized search query string to look up on Google "
"(e.g. 'Tesla Model Y 2026 pricing specifications')."
)
},
"location": {
"type": "string",
"description": "Geographical location filter for search targeting. Defaults to 'United States'.",
"default": "United States"
}
},
"required": ["query"]
}
}
}
def ask_agent(user_prompt: str):
"""
Sends a query to the LLM along with the available search tool definition.
The LLM determines autonomously whether it needs to execute a Google search.
"""
messages = [
{
"role": "system",
"content": (
"You are a factual, precise AI search agent with live web access via SearchApi. "
"Use the search_web tool when you need current or time-sensitive information."
)
},
{"role": "user", "content": user_prompt}
]
# Request model execution with the tools parameter exposed
response = openai_client.chat.completions.create(
model="gpt-4o-mini", # Efficient, tool-capable model
messages=messages,
tools=[search_tool_definition],
tool_choice="auto" # Let the model autonomously decide whether to use the tool
)
response_message = response.choices[0].message
tool_calls = response_message.tool_calls
if tool_calls:
print("Agent decision: 'I need to use SearchApi to look up live information on the web.'")
return {"status": "tool_call_required", "data": tool_calls, "message_history": messages}
else:
print("Agent decision: 'I can answer this question from my existing knowledge.'")
return {"status": "completed", "data": response_message.content}
The tool_choice="auto" parameter is the key to the agent's autonomy. It tells OpenAI to let the model decide whether external search is necessary rather than forcing a tool call on every request. Simple questions like "What is Python?" get answered directly, while questions about current events correctly trigger a SearchApi call.
Step 3: Stitching the search agent loop together
The final piece is the orchestrator - a runtime loop that ties everything together. It interprets the model's decision, calls search_web() with the model's chosen arguments, injects the live results back into the conversation history, and requests a final synthesized answer from OpenAI.
Create a file called run.py:
import json
from agent import ask_agent, openai_client
from search_helper import search_web
def run_agent_loop(user_prompt: str):
"""
The orchestrator runtime loop. It prompts the agent, detects tool requests,
executes the local SearchApi call, updates the conversation history, and
generates the final web-informed answer.
"""
print(f"\nUser: {user_prompt}")
# First turn: ask the agent what it wants to do
result = ask_agent(user_prompt)
# Case A: The LLM answered immediately without needing the web
if result["status"] == "completed":
print(f"\nFinal answer:\n{result['data']}\n")
return
# Case B: The LLM explicitly requested a SearchApi tool call
if result["status"] == "tool_call_required":
tool_calls = result["data"]
messages = result["message_history"]
# Append the model's tool request to keep the chat history balanced
assistant_msg = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
} for tc in tool_calls
]
}
messages.append(assistant_msg)
# Process each tool call (handles potential parallel calls from the model)
for tool_call in tool_calls:
function_name = tool_call.function.name
function_args = json.loads(tool_call.function.arguments)
print(f"Invoking tool: {function_name}() with arguments {function_args}")
if function_name == "search_web":
search_query = function_args.get("query")
search_location = function_args.get("location", "United States")
# Fetch structured data live from SearchApi.io
raw_search_results = search_web(query=search_query, location=search_location)
# Append the search result payload back to the chat history
tool_response_msg = {
"role": "tool",
"tool_call_id": tool_call.id,
"name": function_name,
"content": raw_search_results
}
messages.append(tool_response_msg)
else:
print(f"Warning: model requested unknown tool: {function_name}")
return
# Second turn: send the full conversation history back to OpenAI
# (This now contains: user query + agent tool call + SearchApi results)
print("Synthesizing live search data into a comprehensive answer...")
final_response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=messages
)
print(f"\nFinal answer:\n{final_response.choices[0].message.content}\n")
# End-to-end system test
if __name__ == "__main__":
# Test case 1: Requires SearchApi intervention (current events)
run_agent_loop("What were the top 3 tech stock market trends in Q1 of 2026?")
# Test case 2: Native knowledge execution (no web tool needed)
run_agent_loop("Explain the fundamental difference between a SQL and NoSQL database in two sentences.")
Pay close attention to how the messages list is built across both turns. After the model requests a tool call, you must append the assistant's tool request and then the tool's response before sending the history back to OpenAI. Omitting either step causes the API to return an error - this is the most common breaking point for developers new to tool calling.
Live test drives
With all three files in place, run the agent with python run.py. Here is what a real execution looks like for each scenario.
Scenario A - Current events query:
User: What were the top 3 tech stock market trends in Q1 of 2026?
Agent decision: 'I need to use SearchApi to look up live information on the web.'
Invoking tool: search_web() with arguments {'query': 'top tech stock market trends Q1 2026'}
Synthesizing live search data into a comprehensive answer...
Final answer:
Based on recent web data, the top three tech stock market trends in Q1 2026 were:
1. AI infrastructure stocks surged as hyperscalers accelerated GPU procurement...
2. Semiconductor supply chain stabilization drove AMD and TSMC to multi-year highs...
3. Enterprise SaaS consolidation continued, with several major M&A announcements...
Scenario B - Native knowledge query:
User: Explain the fundamental difference between a SQL and NoSQL database in two sentences.
Agent decision: 'I can answer this question from my existing knowledge.'
Final answer:
SQL databases organize data into structured tables with predefined schemas and use
relational joins to link data across tables, making them ideal for consistent,
transactional workloads. NoSQL databases use flexible document, key-value, or graph
data models with no fixed schema, trading strict consistency for horizontal scalability
and performance on unstructured or rapidly evolving data.
The agent correctly identifies that the first query requires live web data and that the second can be answered from its own training knowledge - skipping an unnecessary API call entirely.
Demo: see the agent in action
The interactive demo below visualizes the exact agent loop described above - including the tool decision badge, the SearchApi result cards, and the synthesized answer panel.
Interactive Demos & Repositories:
- 🚀 Live Web Application: Test the full-stack agent live on Vercel at realtime-search.vercel.app.
- 🐍 Python CLI Repository: Clone the Python tutorial source code at Eunit99/ai-search-agent.
- ⚡ Next.js App Repository: Clone the production web application code at Eunit99/realtime-search.
- Full-Stack Next.js Application: Located in
realtime-search/- features server-side API key isolation (/api/search), real-time OpenAI GPT-4o-mini SSE streaming (/api/synthesize), geo-targeting, and query history persistence.
Production best practices
Before shipping this agent to production, consider these refinements.
Filter low-quality snippets to save tokens
SearchApi's organic_results array occasionally includes results with very short or unhelpful snippets (e.g. login-gated pages or index-only entries). Add a simple length filter to skip any snippet under 40 characters:
if len(snippet) > 40:
formatted_results.append(f"[{index}] {title}\nSnippet: {snippet}\nSource: {link}\n---")
Use geo-targeting for localized results
SearchApi supports a location parameter that localizes search results geographically. This is useful for agents that serve users across different regions, where price, news, or availability data differs by market. The search_web() function already exposes this parameter:
search_web(query="current petrol prices", location="Lagos, Nigeria")
Cache duplicate queries
In production, users frequently ask similar questions within a short time window. Caching the search_web() response avoids burning SearchApi credits on identical lookups. A Redis-backed wrapper keeps it simple:
import hashlib, redis
cache = redis.Redis(host='localhost', port=6379, db=0)
TTL_SECONDS = 300 # Cache results for 5 minutes
def search_web_cached(query: str, location: str = "United States") -> str:
cache_key = hashlib.md5(f"{query}:{location}".encode()).hexdigest()
cached = cache.get(cache_key)
if cached:
return cached.decode()
result = search_web(query, location)
cache.setex(cache_key, TTL_SECONDS, result)
return result
Keep the system prompt specific
Your system prompt directly affects how often the model decides to call search_web. Be explicit about when the tool should and should not be used:
"Use the search_web tool for any question involving current events, prices, "
"people, companies, or anything that may have changed after your training cutoff. "
"Answer directly only for timeless facts, historical events, or technical concepts."
Wrapping up
You have built a production-ready AI search agent that solves the two fundamental limitations of offline LLMs: knowledge cutoffs and hallucinations on real-time data.
The architecture is intentionally minimal but extensible. The search_web() function can be swapped for any SearchApi engine (News, YouTube, Google Scholar, and more). The tool schema can be extended with additional tools for math, code execution, or database lookup. The agent loop can be wrapped in a web server, a Slack bot, or a voice assistant.
The key insight is that SearchApi handles the hard infrastructure work - proxy rotation, CAPTCHA solving, HTML parsing, and structured JSON delivery - so you can focus entirely on the reasoning layer on top of it.
Ready to build? Grab your free API key at SearchApi.io, check out the live web app on Vercel, clone the Python CLI repository from GitHub (ai-search-agent) or the full-stack web application from GitHub (realtime-search), and drop a comment describing what you build.
Have questions or improvements? Open an issue or submit a pull request on GitHub (Python CLI) or GitHub (Next.js App).
This post is sponsored by SearchApi.



Top comments (0)