All articles
Build Guides

Build a Multi-Agent Research Assistant with Gemini, Planning & Search

FDE Coach EditorialAugust 17, 202611 min read

What We’re Building

We're shipping a multi-agent research assistant that takes a loose topic, plans sharp research questions, hits the live web for answers, and produces a concise, cited brief. No paid APIs, no GPU rental, no MCP servers. Just free-tier tools wired together with clean Python.

Feature list:

  • Planner Agent breaks a broad topic into 3–5 specific research questions.
  • Searcher Agent runs each question through SerpAPI’s free tier and extracts relevant snippets.
  • Writer Agent synthesizes findings into a markdown brief with inline citations.
  • Streamlit UI ties it together—paste a topic, get a brief.
  • Session state keeps intermediate outputs visible so you can debug agent reasoning.

This pattern generalizes. Once you see how to decompose a task across stateless LLM calls, you can apply it to due diligence, competitive intel, or content drafting. For a deeper dive on where multi-agent setups fail in production, check out Multi-Agent Systems: Emerging Architectural Patterns and Failure Modes.

Architecture: Three Agents, One Pipeline

The system is a linear DAG—no loops, no message-passing between agents beyond the serialized output of the previous step. This keeps latency predictable and debuggable.

Why this separation? A single prompt that plans, searches, and writes would hallucinate search results. By forcing the Searcher to return structured snippets before the Writer runs, we ground the final output in real data. The Planner ensures we don’t waste SerpAPI calls on vague queries.

Prerequisites: Free Tier Stack

Every tool here has a genuine free tier. No credit card required for the first two.

ToolPurposeFree Tier LimitSign-Up Link
Google Gemini APILLM for planning and writing15 requests/min, 1,500/day on gemini-1.5-flashaistudio.google.com
SerpAPIGoogle Search results as structured JSON100 searches/monthserpapi.com (free plan)
LangChainPrompt templates, output parsing, chainingOpen-source (MIT)pip install langchain langchain-google-genai
StreamlitUI shellOpen-source, free Community Cloud hostingpip install streamlit

Gemini model choice: Use gemini-1.5-flash. It’s fast, free-tier generous, and more than capable for planning and summarization. gemini-1.5-pro is overkill here and burns through your rate limit.

Step 1: Project Setup and API Keys

Create a project directory and a virtual environment. Don’t skip the venv—dependency conflicts between google-generativeai and langchain-google-genai are real.

mkdir research-assistant && cd research-assistant
python -m venv .venv && source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install streamlit langchain langchain-google-genai google-search-results python-dotenv

Create a .env file at the project root:

GOOGLE_API_KEY=your-gemini-api-key-here
SERPAPI_API_KEY=your-serpapi-key-here

Getting keys:

Create app.py and load them:

import os
from dotenv import load_dotenv
load_dotenv()

GEMINI_KEY = os.getenv("GOOGLE_API_KEY")
SERPAPI_KEY = os.getenv("SERPAPI_API_KEY")

if not GEMINI_KEY or not SERPAPI_KEY:
    raise RuntimeError("Missing API keys in .env file")

Step 2: The Planner Agent (Gemini)

The Planner takes a user topic and returns a list of research questions. We use LangChain’s ChatGoogleGenerativeAI with a structured output parser so we get a clean Python list, not a paragraph we have to regex apart.

from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.output_parsers import CommaSeparatedListOutputParser
from langchain.prompts import ChatPromptTemplate

planner_llm = ChatGoogleGenerativeAI(
    model="gemini-1.5-flash",
    google_api_key=GEMINI_KEY,
    temperature=0.3,  # Low temp for deterministic planning
)

planner_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a research planner. Given a topic, generate 4 specific, 
answerable research questions. Each question should target a distinct sub-angle 
(factual, financial, competitive, technical, temporal). 
Return ONLY a comma-separated list. No numbering, no bullets."""),
    ("human", "Research topic: {topic}")
])

planner_chain = planner_prompt | planner_llm | CommaSeparatedListOutputParser()

def plan_research(topic: str) -> list[str]:
    questions = planner_chain.invoke({"topic": topic})
    # Strip whitespace, drop empty strings
    return [q.strip() for q in questions if q.strip()]

Why CommaSeparatedListOutputParser? It’s battle-tested for simple list extraction. For more complex structured outputs, you’d use PydanticOutputParser, but that adds latency and complexity we don’t need here.

Step 3: The Searcher Agent (SerpAPI + LangChain)

For each question, we hit SerpAPI’s organic search results and extract titles, snippets, and links. We wrap it in LangChain’s Tool abstraction so we could swap in a different search backend later without touching orchestration code.

from serpapi import GoogleSearch
from langchain.tools import tool

@tool
def search_web(query: str) -> list[dict]:
    """Search the web for a query and return organic results with title, snippet, link."""
    params = {
        "q": query,
        "api_key": SERPAPI_KEY,
        "num": 5,  # Free tier: keep it low
        "engine": "google",
    }
    search = GoogleSearch(params)
    results = search.get_dict()
    organic = results.get("organic_results", [])
    
    return [
        {
            "title": r.get("title", ""),
            "snippet": r.get("snippet", ""),
            "link": r.get("link", ""),
        }
        for r in organic
    ]

def run_searches(questions: list[str]) -> dict[str, list[dict]]:
    """Run all research questions through search, return mapping."""
    findings = {}
    for q in questions:
        findings[q] = search_web.invoke(q)
    return findings

Free tier guard: SerpAPI’s free plan gives 100 searches/month. With 4 questions per topic, that’s 25 research briefs per month. If you hit the limit, the API returns a 403—we’ll handle that in the UI.

Step 4: The Writer Agent (Gemini with Citations)

The Writer receives the original topic, research questions, and search findings. It produces a markdown brief where every factual claim is backed by an inline citation link. We use a detailed system prompt to enforce structure.

writer_llm = ChatGoogleGenerativeAI(
    model="gemini-1.5-flash",
    google_api_key=GEMINI_KEY,
    temperature=0.5,  # Slightly higher for natural prose
)

def format_findings(findings: dict) -> str:
    """Convert findings dict into a flat text block the LLM can consume."""
    blocks = []
    for question, results in findings.items():
        blocks.append(f"### Question: {question}")
        for i, r in enumerate(results, 1):
            blocks.append(f"Source {i}: {r['title']}")
            blocks.append(f"Snippet: {r['snippet']}")
            blocks.append(f"URL: {r['link']}")
            blocks.append("")
    return "\n".join(blocks)

writer_prompt = ChatPromptTemplate.from_messages([
    ("system", """You are a research analyst. Write a concise brief (300-500 words) in markdown.

Rules:
1. Open with a 2-sentence executive summary.
2. Organize body by research question, using ## headings.
3. Every factual claim MUST include an inline citation: [Source X](URL).
4. If sources conflict, note the disagreement.
5. End with a "## Limitations" section noting what the search didn't cover.
6. Do not fabricate information not present in the provided snippets."""),
    ("human", """Topic: {topic}

Research Findings:
{findings}

Write the brief.""")
])

writer_chain = writer_prompt | writer_llm

def write_brief(topic: str, findings: dict) -> str:
    formatted = format_findings(findings)
    response = writer_chain.invoke({"topic": topic, "findings": formatted})
    return response.content

Step 5: Orchestration in Streamlit

The UI is a single-page Streamlit app that calls the three agents in sequence and displays intermediate outputs. We use st.session_state to avoid re-running expensive API calls on every widget interaction.

import streamlit as st

st.set_page_config(page_title="Research Assistant", layout="wide")
st.title("Multi-Agent Research Assistant")
st.caption("Planner → Searcher → Writer, all on free-tier Gemini + SerpAPI")

# Initialize session state
if "brief" not in st.session_state:
    st.session_state.brief = None
if "questions" not in st.session_state:
    st.session_state.questions = None
if "findings" not in st.session_state:
    st.session_state.findings = None

topic = st.text_input("Research Topic", placeholder="e.g., Impact of EU AI Act on open-source models")

if st.button("Generate Brief", type="primary") and topic:
    with st.spinner("Planning research questions..."):
        st.session_state.questions = plan_research(topic)
    
    with st.spinner(f"Searching {len(st.session_state.questions)} questions..."):
        try:
            st.session_state.findings = run_searches(st.session_state.questions)
        except Exception as e:
            st.error(f"Search failed: {e}. Check your SerpAPI quota.")
            st.stop()
    
    with st.spinner("Writing brief..."):
        st.session_state.brief = write_brief(topic, st.session_state.findings)

# Display results
if st.session_state.questions:
    with st.expander("🔍 Research Questions", expanded=False):
        for i, q in enumerate(st.session_state.questions, 1):
            st.write(f"{i}. {q}")

if st.session_state.findings:
    with st.expander("📄 Raw Search Results", expanded=False):
        for q, results in st.session_state.findings.items():
            st.subheader(q)
            for r in results:
                st.write(f"- [{r['title']}]({r['link']}): {r['snippet'][:200]}...")

if st.session_state.brief:
    st.markdown("---")
    st.markdown(st.session_state.brief)
    st.download_button(
        "Download Brief",
        st.session_state.brief,
        file_name=f"{topic[:30].replace(' ', '_')}_brief.md",
    )

How to Run It

streamlit run app.py

Open http://localhost:8501. Paste a topic, hit "Generate Brief", and watch the three agents execute in sequence. The expanders let you inspect planner output and raw search results before the writer runs.

Deploying for free: Push to GitHub, connect to Streamlit Community Cloud, add your .env variables as secrets in the dashboard. Free hosting with no cold-start issues for low-traffic apps.

Sensible Extensions

1. Add a Critic Agent. After the Writer produces a brief, run a fourth Gemini call that fact-checks claims against the original snippets and flags unsupported statements. This is the pattern we explore in Multi-Agent Systems: Emerging Architectural Patterns and Failure Modes—verification loops catch hallucinations that single-pass generation misses.

2. Persistent search cache. SerpAPI calls are the bottleneck. Cache results to SQLite keyed by query string. On repeat runs, skip the API call. This also helps when you iterate on the Writer prompt without burning searches.

3. Switch to Tavily for deeper search. Tavily’s free tier offers 1,000 requests/month and returns cleaner, LLM-optimized snippets. Swap the search_web tool implementation—the LangChain Tool abstraction makes this a one-function change.

4. Streaming output. Use streamlit.write_stream() with Gemini’s streaming API to show the brief as it’s written. Better UX for long briefs.

If you’re building these kinds of integration scaffolds regularly, the tooling patterns are exactly what we cover in The Tools an FDE Ships With: Data Pipelines, Integration Scaffolds, and Demo Kits.

Common Pitfalls and Fixes

PitfallSymptomFix
Planner returns a paragraph, not a listCommaSeparatedListOutputParser failsIncrease prompt specificity: "Return ONLY a comma-separated list. No numbering." Or switch to PydanticOutputParser with a Questions model.
SerpAPI 403 errorsSearch step crashesYou hit the 100/month free limit. Add a try/except and show a clear quota message. Cache results aggressively.
Writer hallucinates URLsCitations point to non-existent pagesThe Writer can only cite URLs present in the findings. If it fabricates, lower temperature to 0.2 and add: "Only cite URLs explicitly provided in the findings."
Gemini rate limit (429)Planning or writing step hangsgemini-1.5-flash allows 15 RPM. Add a 4-second sleep between agent calls if you’re running in a loop.
Streamlit re-runs on every interactionAPI calls fire multiple timesUse st.session_state as shown. The button click sets state; re-renders read from state without re-invoking agents.
SerpAPI returns zero organic resultsEmpty findings for a questionSome queries (very niche, very new) return no organic results. Add a fallback: if organic_results is empty, try a broader reformulation of the query.

FAQ

Q: Why Gemini instead of GPT-4o or Claude? A: Gemini’s free tier is the most generous for this workload—1,500 requests/day on a capable model. GPT-4o’s free tier is rate-limited to the point of unusability for multi-step pipelines. Claude’s free tier (via Anthropic Console) doesn’t expose an API. If you need to operationalize model behavior more carefully, Claude System Prompts: Operationalizing Model Behavior at the API Layer covers the prompt-engineering patterns that apply regardless of model.

Q: Can I use this for commercial purposes? A: The code is yours. SerpAPI’s free tier is for non-commercial use per their ToS. Gemini’s free tier allows commercial use but with data retention caveats—read Google’s terms. For production, upgrade to paid plans.

Q: How do I add more agents? A: The pattern is always the same: define a function that takes the previous agent’s output, call an LLM or tool, return structured data. Chain them in Streamlit’s button handler. The architecture doesn’t change—just add another node to the DAG.

Q: The brief quality is inconsistent. What gives? A: Garbage in, garbage out. If SerpAPI returns low-quality snippets (thin content, SEO spam), the Writer has nothing to work with. Add a snippet quality filter before the Writer: drop results where snippet length < 50 characters or where the domain is a known content mill.

Q: Can I run this entirely locally? A: Not with this exact stack. SerpAPI requires internet. But you could swap it for a local SearXNG instance and swap Gemini for Ollama with a local model. The LangChain abstractions make the swap straightforward—change the model name and base URL.

Q: How do I turn this into a deployable product? A: This is exactly the kind of integration scaffold FDEs build to prove value in enterprise settings. For the full playbook on shipping these as demo kits that win deals, see How AI-Native Startups Use FDEs to Win and Expand Enterprise Deals.

#multi-agent#research#gemini#langchain

Want to build like a Forward Deployed Engineer?

FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.

Explore the program

More build guides

August 15 · 0d left
Enroll Now