n4nAI

Haystack agent pipeline tutorial with web search tools

Build a Haystack 2.x agent pipeline with web search tools — prerequisites, runnable code, and production patterns for reliable tool use.

n4n Team3 min read554 words

Audio narration

Coming soon — every post will get a voice note here.

Haystack 2.x moved agents from experimental to production-ready. The Agent class now handles tool calling, memory, and multi-step reasoning out of the box. This tutorial builds a working agent that searches the web, extracts answers, and cites sources — using only components you can run locally or with free API tiers.

Prerequisites

  • Python 3.10+
  • An OpenAI API key (or any OpenAI-compatible endpoint)
  • A Serper.dev API key for web search (free tier: 2,500 queries/month)
  • Basic familiarity with Haystack 2.x components and pipelines

Install the dependencies:

pip install haystack-ai==2.7.0 \
    haystack-components==0.3.0 \
    python-dotenv==1.0.1 \
    requests==2.32.3

Create a .env file:

OPENAI_API_KEY=sk-...
SERPER_API_KEY=...

Minimal agent with one search tool

Haystack’s Agent expects a list of Tool objects. Each tool wraps a component with a name, description, and the component itself. Start with a single web search tool using SerperDev.

# minimal_agent.py
import os
from dotenv import load_dotenv
from haystack import Pipeline
from haystack.components.agents import Agent
from haystack.components.generators import OpenAIGenerator
from haystack.components.tools import Tool
from haystack.components.websearch import SerperDevWebSearch
from haystack.dataclasses import ChatMessage

load_dotenv()

# 1. Configure the LLM
llm = OpenAIGenerator(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    generation_kwargs={"temperature": 0.1}
)

# 2. Create the search component
search = SerperDevWebSearch(
    api_key=os.getenv("SERPER_API_KEY"),
    top_k=5
)

# 3. Wrap it as a tool
search_tool = Tool(
    name="web_search",
    description="Search the web for current information. Use for facts, news, prices, or anything not in training data.",
    component=search
)

# 4. Build the agent
agent = Agent(
    tools=[search_tool],
    generator=llm,
    system_prompt=(
        "You are a research assistant. Answer questions using the web_search tool. "
        "Always cite sources with URLs. If the tool returns no results, say so."
    ),
    max_steps=5
)

# 5. Run a query
question = "What is the current price of a Tesla Model 3 in the US?"
result = agent.run(messages=[ChatMessage.from_user(question)])

# 6. Print the final answer
for msg in result["messages"]:
    if msg.role == "assistant" and msg.text:
        print(msg.text)

Run it:

python minimal_agent.py

Expected output (prices will vary):

Based on current web search results, the Tesla Model 3 pricing in the US as of 2024:

**Model 3 RWD (Standard Range):** $38,990
**Model 3 Long Range AWD:** $45,990  
**Model 3 Performance:** $52,990

These are base prices before incentives. Federal tax credit of up to $7,500 may apply depending on battery sourcing and income limits.

Sources:
- https://www.tesla.com/model3/design - Official Tesla configurator
- https://www.caranddriver.com/tesla/model-3 - Car and Driver pricing summary
- https://www.edmunds.com/tesla/model-3/ - Edmunds pricing analysis

The agent called web_search, synthesized the results, and cited URLs. Check the raw tool output by inspecting result["messages"] — you’ll see ToolCall and ToolCallResult messages in the conversation history.

Adding a second tool: content extraction

Search snippets often lack detail. Add a second tool that fetches and extracts full page content.

# agent_with_extraction.py
import os
from dotenv import load_dotenv
from haystack import Pipeline
from haystack.components.agents import Agent
from haystack.components.generators import OpenAIGenerator
from haystack.components.tools import Tool
from haystack.components.websearch import SerperDevWebSearch
from haystack.components.fetchers import LinkContentFetcher
from haystack.components.converters import HTMLToDocument
from haystack.dataclasses import ChatMessage

load_dotenv()

llm = OpenAIGenerator(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    generation_kwargs={"temperature": 0.1}
)

search = SerperDevWebSearch(
    api_key=os.getenv("SERPER_API_KEY"),
    top_k=5
)

# Fetch + convert pipeline for extraction
fetcher = LinkContentFetcher()
converter = HTMLToDocument()

extract_pipeline = Pipeline()
extract_pipeline.add_component("fetcher", fetcher)
extract_pipeline.add_component("converter", converter)
extract_pipeline.connect("fetcher.streams", "converter.sources")

extract_tool = Tool(
    name="extract_content",
    description="Fetch and extract full text from a URL. Use after web_search to get details from specific sources.",
    component=extract_pipeline,
    parameters={
        "type": "object",
        "properties": {
            "urls": {"type": "array", "items": {"type": "string"}}
        },
        "required": ["urls"]
    }
)

agent = Agent(
    tools=[search_tool, extract_tool],
    generator=llm,
    system_prompt=(
        "You are a research assistant. Use web_search to find relevant URLs, then "
        "use extract_content to get full details from the most promising sources. "
        "Always cite sources with URLs."
    ),
    max_steps=8
)

question = "What are the key differences between Python 3.12 and 3.13?"
result = agent.run(messages=[ChatMessage.from_user(question)])

for msg in result["messages"]:
    if msg.role == "assistant" and msg.text:
        print(msg.text)

Expected output structure:

Python 3.13 (released October 2024) introduces several key changes over 3.12:

**Performance**
- 10-15% faster interpreter startup via tier 2 compilation (PEP 744)
- Improved `list` and `dict` operations through specialized adaptive interpreter

**New Features**
- `dbm.sqlite3` backend for `dbm` module (PEP 751)
- `random.random()` now uses ChaCha20 for better quality
- Experimental JIT compiler (disabled by default, enable with `--enable-experimental-jit`)

**Removals/Deprecations**
- `cgi` module removed (deprecated since 3.11)
- `cgitb` module removed
- `telnetlib` removed

**Typing Improvements**
- `typing.TypeIs` for type narrowing (PEP 742)
- `typing.ReadOnly` for TypedDict fields (PEP 705)

Sources:
- https://docs.python.org/3/whatsnew/3.13.html - Official changelog
- https://peps.python.org/pep-0744/ - Tier 2 compilation details

The agent now chains tools: search → select URLs → extract → synthesize. The max_steps=8 prevents infinite loops on complex queries.

Structured output with Pydantic

For production use, you want structured responses — not free text. Define a schema and have the agent return JSON.

# structured_agent.py
import os
import json
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from typing import List
from haystack import Pipeline
from haystack.components.agents import Agent
from haystack.components.generators import OpenAIGenerator
from haystack.components.tools import Tool
from haystack.components.websearch import SerperDevWebSearch
from haystack.components.fetchers import LinkContentFetcher
from haystack.components.converters import HTMLToDocument
from haystack.dataclasses import ChatMessage

load_dotenv()

class Source(BaseModel):
    url: str
    title: str
    snippet: str

class ResearchAnswer(BaseModel):
    question: str
    answer: str
    sources: List[Source]
    confidence: float = Field(ge=0.0, le=1.0)

llm = OpenAIGenerator(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    generation_kwargs={"temperature": 0.0}
)

search = SerperDevWebSearch(
    api_key=os.getenv("SERPER_API_KEY"),
    top_k=5
)

fetcher = LinkContentFetcher()
converter = HTMLToDocument()
extract_pipeline = Pipeline()
extract_pipeline.add_component("fetcher", fetcher)
extract_pipeline.add_component("converter", converter)
extract_pipeline.connect("fetcher.streams", "converter.sources")

search_tool = Tool(
    name="web_search",
    description="Search the web for current information.",
    component=search
)

extract_tool = Tool(
    name="extract_content",
    description="Fetch full content from URLs.",
    component=extract_pipeline,
    parameters={
        "type": "object",
        "properties": {"urls": {"type": "array", "items": {"type": "string"}}},
        "required": ["urls"]
    }
)

# System prompt enforces JSON output matching the schema
system_prompt = f"""You are a research assistant. Use tools to answer the question.
Return ONLY valid JSON matching this schema:
{ResearchAnswer.model_json_schema()}

Rules:
- Search first, then extract from top 2-3 URLs
- Include 3-5 sources with title, URL, and 1-sentence snippet
- Confidence: 0.0-1.0 based on source quality and agreement
- No markdown, no extra text — only the JSON object"""

agent = Agent(
    tools=[search_tool, extract_tool],
    generator=llm,
    system_prompt=system_prompt,
    max_steps=8
)

question = "What is the current federal funds rate in the US?"
result = agent.run(messages=[ChatMessage.from_user(question)])

# Parse and validate
for msg in result["messages"]:
    if msg.role == "assistant" and msg.text:
        try:
            parsed = ResearchAnswer.model_validate_json(msg.text)
            print(json.dumps(parsed.model_dump(), indent=2))
        except Exception as e:
            print(f"Parse failed: {e}")
            print(f"Raw output: {msg.text}")

Output:

{
  "question": "What is the current federal funds rate in the US?",
  "answer": "As of July 2024, the federal funds rate target range is 5.25% - 5.50%, unchanged since July 2023. The FOMC has held this rate steady through 2024 meetings.",
  "sources": [
    {
      "url": "https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm",
      "title": "FOMC Meeting Calendar - Federal Reserve",
      "snippet": "Official FOMC statements confirm 5.25-5.50% target range maintained through June 2024 meeting."
    },
    {
      "url": "https://www.reuters.com/markets/us/fed-holds-interest-rates-steady-2024-06-12/",
      "title": "Fed holds interest rates steady, signals one cut this year",
      "snippet": "Reuters reports Fed kept rates at 5.25-5.50% at June 2024 meeting."
    },
    {
      "url": "https://www.bloomberg.com/news/articles/2024-07-31/fed-rate-decision-july-2024",
      "title": "Fed Rate Decision July 2024",
      "snippet": "Bloomberg confirms no rate change at July 31, 2024 FOMC meeting."
    }
  ],
  "confidence": 0.95
}

The schema forces consistent structure. Downstream consumers can rely on answer, sources, and confidence without parsing prose.

Error handling and retries

Tools fail — network timeouts, rate limits, parsing errors. Wrap tools to handle this gracefully.

# resilient_agent.py
import os
import time
from dotenv import load_dotenv
from haystack import Pipeline, component
from haystack.components.agents import Agent
from haystack.components.generators import OpenAIGenerator
from haystack.components.tools import Tool
from haystack.components.websearch import SerperDevWebSearch
from haystack.dataclasses import ChatMessage
from haystack import logging

load_dotenv()
logging.basicConfig(level=logging.INFO)

llm = OpenAIGenerator(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    generation_kwargs={"temperature": 0.1}
)

# Custom component with retry logic
@component
class ResilientWebSearch:
    def __init__(self, api_key: str, top_k: int = 5, max_retries: int = 3):
        self.search = SerperDevWebSearch(api_key=api_key, top_k=top_k)
        self.max_retries = max_retries

    @component.output_types(results=list)
    def run(self, query: str):
        last_error = None
        for attempt in range(self.max_retries):
            try:
                result = self.search.run(query=query)
                return {"results": result["results"]}
            except Exception as e:
                last_error = e
                wait = 2 ** attempt  # exponential backoff
                logging.warning(f"Search attempt {attempt + 1} failed: {e}. Retrying in {wait}s...")
                time.sleep(wait)
        # All retries exhausted — return empty results instead of crashing
        logging.error(f"All retries exhausted for query: {query}")
        return {"results": []}

resilient_search = ResilientWebSearch(
    api_key=os.getenv("SERPER_API_KEY"),
    top_k=5,
    max_retries=3
)

search_tool = Tool(
    name="web_search",
    description="Search the web with automatic retries on failure.",
    component=resilient_search
)

agent = Agent(
    tools=[search_tool],
    generator=llm,
    system_prompt="Search the web and answer. If search returns no results, say you couldn't find current information.",
    max_steps=5
)

# Test with a query that might hit rate limits
result = agent.run(messages=[ChatMessage.from_user("Latest SpaceX Starship launch date?")])

for msg in result["messages"]:
    if msg.role == "assistant" and msg.text:
        print(msg.text)

The @component decorator makes ResilientWebSearch a first-class Haystack component. It returns empty results on total failure rather than raising — the agent sees a valid (empty) tool result and can respond gracefully.

Running in a pipeline for observability

For production, wrap the agent in a Pipeline to add logging, tracing, or custom components before/after.

# pipeline_agent.py
import os
from dotenv import load_dotenv
from haystack import Pipeline
from haystack.components.agents import Agent
from haystack.components.generators import OpenAIGenerator
from haystack.components.tools import Tool
from haystack.components.websearch import SerperDevWebSearch
from haystack.dataclasses import ChatMessage
from haystack import component, logging

load_dotenv()
logging.basicConfig(level=logging.INFO)

@component
class QueryLogger:
    @component.output_types(query=str)
    def run(self, query: str):
        logging.info(f"Incoming query: {query}")
        return {"query": query}

@component
class ResponseLogger:
    @component.output_types(answer=str)
    def run(self, messages: list):
        # Extract last assistant message
        for msg in reversed(messages):
            if msg.role == "assistant" and msg.text:
                logging.info(f"Final answer length: {len(msg.text)} chars")
                return {"answer": msg.text}
        return {"answer": ""}

llm = OpenAIGenerator(
    model="gpt-4o-mini",
    api_key=os.getenv("OPENAI_API_KEY"),
    generation_kwargs={"temperature": 0.1}
)

search = SerperDevWebSearch(
    api_key=os.getenv("SERPER_API_KEY"),
    top_k=5
)

search_tool = Tool(
    name="web_search",
    description="Search the web for current information.",
    component=search
)

agent = Agent(
    tools=[search_tool],
    generator=llm,
    system_prompt="Answer using web search. Cite sources.",
    max_steps=5
)

pipeline = Pipeline()
pipeline.add_component("log_query", QueryLogger())
pipeline.add_component("agent", agent)
pipeline.add_component("log_response", ResponseLogger())

pipeline.connect("log_query.query", "agent.messages")  # Note: type mismatch handled by Agent
pipeline.connect("agent.messages", "log_response.messages")

# Run through pipeline
question = "Who won the 2024 Super Bowl?"
result = pipeline.run(data={"log_query": {"query": question}})

print(result["log_response"]["answer"])

This pattern scales: add a TokenCounter component, a PIIRedactor, or route to different LLMs based on query classification — all without changing the agent logic.

Production checklist

Before deploying:

  1. Rate limiting — SerperDev allows 2,500 free queries/month. Implement client-side throttling or switch to a paid tier. If you’re routing through a gateway like n4n.ai, you can enforce per-key quotas at the gateway level.

  2. Token costs — Each agent step sends full conversation history. max_steps=5 with gpt-4o-mini typically costs $0.001–0.005 per query. Set a hard token budget in the generator’s generation_kwargs.

  3. Source freshness — SerperDev returns a date field in results. Filter out sources older than your threshold (e.g., 30 days for pricing, 1 year for technical specs).

  4. Evaluation — Build a small eval set (20–50 questions with expected answers). Run nightly to catch regressions when model versions or search indexes change.

  5. Fallback — If search returns zero results, fall back to the LLM’s internal knowledge with a disclaimer: “No current sources found; this is based on training data up to 2024.”

# Fallback pattern
if not search_results:
    fallback_prompt = (
        "No current web results found. Answer from your training data "
        "and prefix with '[Training data only]'."
    )
    # Re-run agent with modified system prompt or call generator directly

Next steps

  • Replace SerperDevWebSearch with BraveWebSearch or DuckDuckGoWebSearch for different indexes
  • Add a Calculator tool for math-heavy queries
  • Implement Tool subclasses that enforce output schemas via Pydantic
  • Add conversation memory with ChatMessageHistory for multi-turn research sessions

The agent pattern here — search → extract → synthesize → structure — handles 80% of research-style queries. The remaining 20% need domain-specific tools (SQL, API clients, code execution). Start simple, measure, then extend.

Tagshaystackagentweb-searchtools

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All haystack 2.0 agent pipelines posts →