Build a Multi-Agent Research Assistant with OpenRouter and Playwright
What We're Building
A multi-agent system that takes a research topic and returns a structured, sourced brief. Three agents collaborate: a Planner that decomposes the topic into search queries, a Searcher that executes those queries and scrapes results with Playwright, and a Writer that synthesizes findings into a polished brief.
Feature list:
- Natural language topic input (e.g., "impact of weight decay on transformer training stability")
- Automated query planning via OpenRouter (free-tier models)
- Live web search and content extraction with headless Playwright
- Structured brief generation via Hugging Face Inference API (free credits)
- All-agent orchestration with retry logic and error handling
- Runs entirely on free-tier services—zero cost to operate
This isn't a toy. It mirrors the agentic pipelines we build at FDE Coach for enterprise research automation, just scaled to free tooling.
Architecture: How the Pieces Fit
The Orchestrator holds the state machine. It calls the Planner to generate 3-5 targeted search queries, fans out to the Searcher for each query (sequential to respect rate limits on free tiers), collects raw text, then passes the corpus to the Writer. Each agent is a pure function with a well-defined I/O contract—no shared mutable state, no spaghetti.
Prerequisites (All Free-Tier)
| Tool | Purpose | Free Tier Limit | Sign-Up Link |
|---|---|---|---|
| OpenRouter | LLM API for Planner agent | ~200 requests/day on free models | openrouter.ai |
| Hugging Face Inference API | LLM for Writer agent | ~30k tokens/month free | huggingface.co/join |
| Playwright (Python) | Headless browser for search + scrape | Unlimited local runs | pip install playwright |
| Python 3.10+ | Runtime | N/A | python.org |
OpenRouter setup: Create an account, generate an API key at openrouter.ai/keys. Copy the key. We'll use google/gemma-3-4b-it:free—it's fast, capable for planning, and costs nothing.
Hugging Face setup: Create an account, go to huggingface.co/settings/tokens, create a read token. We'll use mistralai/Mistral-7B-Instruct-v0.3 via the free Inference API for the Writer.
Playwright: Install browsers after pip install: playwright install chromium. Chromium is all we need.
Step 1: Project Scaffold and Dependencies
mkdir multi-agent-research
cd multi-agent-research
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install playwright openai httpx python-dotenv
playwright install chromium
Create a .env file:
OPENROUTER_API_KEY=sk-or-v1-your-key-here
HF_API_TOKEN=hf_your_token_here
Create config.py:
import os
from dotenv import load_dotenv
load_dotenv()
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
HF_API_TOKEN = os.getenv("HF_API_TOKEN")
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
PLANNER_MODEL = "google/gemma-3-4b-it:free"
WRITER_MODEL = "mistralai/Mistral-7B-Instruct-v0.3"
HF_INFERENCE_URL = f"https://api-inference.huggingface.co/models/{WRITER_MODEL}"
MAX_SCRAPE_CHARS = 4000 # per result, to stay within free context windows
Step 2: The Planner Agent (OpenRouter)
Create agents/planner.py. The Planner receives a research topic and returns a list of search-engine-optimized queries.
import httpx
from config import OPENROUTER_API_KEY, OPENROUTER_BASE, PLANNER_MODEL
SYSTEM_PROMPT = """You are a research query planner. Given a topic, generate 4-5 specific search queries that would surface high-quality, diverse sources. Return ONLY a JSON array of strings, no other text."""
async def plan_queries(topic: str) -> list[str]:
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
}
payload = {
"model": PLANNER_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Research topic: {topic}"}
],
"temperature": 0.7,
"max_tokens": 300,
}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(
f"{OPENROUTER_BASE}/chat/completions",
headers=headers,
json=payload,
)
resp.raise_for_status()
data = resp.json()
raw = data["choices"][0]["message"]["content"].strip()
# Extract JSON array from response (handle occasional markdown fences)
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("\n", 1)[0]
import json
return json.loads(raw)
Why Gemma on OpenRouter? It's free, fast, and handles structured JSON output reliably with the right prompt. If you hit rate limits, add a 2-second sleep between calls.
Step 3: The Search & Scrape Agent (Playwright)
Create agents/searcher.py. This agent searches DuckDuckGo (no API key needed), clicks the first 3 organic results, and extracts text content.
from playwright.async_api import async_playwright
from config import MAX_SCRAPE_CHARS
async def search_and_scrape(query: str, num_results: int = 3) -> list[dict]:
results = []
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
)
page = await context.new_page()
# Search DuckDuckGo
await page.goto(f"https://duckduckgo.com/?q={query}")
await page.wait_for_selector("[data-testid='result']", timeout=10000)
# Collect result links (organic only, skip ads)
link_elements = await page.query_selector_all("[data-testid='result'] a[data-testid='result-title-a']")
urls = []
for el in link_elements[:num_results]:
href = await el.get_attribute("href")
if href and href.startswith("http"):
urls.append(href)
# Scrape each result
for url in urls:
try:
await page.goto(url, timeout=15000)
# Extract main text: grab <p> tags inside <article> or <main>, fallback to body
text = await page.evaluate("""
() => {
const article = document.querySelector('article') || document.querySelector('main') || document.body;
const paragraphs = article.querySelectorAll('p');
return Array.from(paragraphs).map(p => p.innerText).join('\\n');
}
""")
results.append({
"url": url,
"text": text[:MAX_SCRAPE_CHARS],
"title": await page.title(),
})
except Exception as e:
results.append({"url": url, "text": "", "title": "", "error": str(e)})
await browser.close()
return results
This is the heavy lifter. DuckDuckGo's HTML selectors are stable enough for our purposes. The user_agent header prevents bot detection. We cap text at 4000 chars to keep the Writer's context manageable on free-tier models.
Step 4: The Writer Agent (Hugging Face Inference)
Create agents/writer.py. The Writer receives the original topic and all scraped texts, then produces a structured brief.
import httpx
from config import HF_API_TOKEN, HF_INFERENCE_URL
WRITER_PROMPT_TEMPLATE = """You are a research synthesizer. Write a structured brief on the topic below using ONLY the provided sources. Format:
## Summary
(2-3 sentence overview)
## Key Findings
- Bullet point 1
- Bullet point 2
...
## Source Summary Table
| Source | Key Claim |
|--------|-----------|
| ... | ... |
## Gaps / Further Research
- What's missing
Topic: {topic}
Sources:
{sources}"""
async def write_brief(topic: str, sources: list[dict]) -> str:
sources_text = "\n\n---\n\n".join(
f"Source: {s['title']} ({s['url']})\n{s['text']}" for s in sources if s.get("text")
)
prompt = WRITER_PROMPT_TEMPLATE.format(topic=topic, sources=sources_text)
headers = {"Authorization": f"Bearer {HF_API_TOKEN}"}
payload = {
"inputs": prompt,
"parameters": {"max_new_tokens": 1200, "temperature": 0.3, "return_full_text": False}
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(HF_INFERENCE_URL, headers=headers, json=payload)
# Handle model loading (503 with estimated time)
if resp.status_code == 503:
estimated_time = resp.json().get("estimated_time", 20)
import asyncio
await asyncio.sleep(estimated_time + 2)
resp = await client.post(HF_INFERENCE_URL, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
return data[0]["generated_text"]
Why Hugging Face Inference? The free tier gives us access to Mistral 7B without hosting a GPU. The 503 retry pattern is essential—cold starts are common on free inference endpoints. For more reliable production runs, check out our guide on deploying LLM features at scale: /blog/case-study-deploy-llm-feature-enterprise.
Step 5: The Orchestrator
Create main.py. This ties the three agents together with a clean async pipeline.
import asyncio
import json
from agents.planner import plan_queries
from agents.searcher import search_and_scrape
from agents.writer import write_brief
async def research(topic: str) -> str:
print(f"[Orchestrator] Planning queries for: {topic}")
queries = await plan_queries(topic)
print(f"[Orchestrator] Got {len(queries)} queries: {queries}")
all_sources = []
for i, query in enumerate(queries):
print(f"[Orchestrator] Searching ({i+1}/{len(queries)}): {query}")
sources = await search_and_scrape(query)
print(f"[Orchestrator] Scraped {len(sources)} results")
all_sources.extend(sources)
await asyncio.sleep(1) # Be polite to DuckDuckGo
# Deduplicate by URL
seen = set()
unique_sources = []
for s in all_sources:
if s["url"] not in seen:
seen.add(s["url"])
unique_sources.append(s)
print(f"[Orchestrator] Total unique sources: {len(unique_sources)}")
print("[Orchestrator] Writing brief...")
brief = await write_brief(topic, unique_sources)
return brief
if __name__ == "__main__":
topic = input("Enter research topic: ")
brief = asyncio.run(research(topic))
print("\n" + "="*60)
print(brief)
print("="*60)
# Save to file
with open("brief.md", "w") as f:
f.write(f"# Research Brief: {topic}\n\n{brief}")
print("\nBrief saved to brief.md")
How to Run It
source venv/bin/activate
python main.py
# Enter: impact of weight decay on transformer training stability
Expected runtime: 45-90 seconds depending on free-tier model cold starts. The output lands in brief.md with a structured Markdown document.
What you'll see:
- Planner returns 4-5 queries like
"weight decay regularization transformer training","L2 regularization vs weight decay deep learning" - Searcher opens headless Chromium, searches DuckDuckGo, scrapes top 3 results per query
- Writer synthesizes 12-15 sources into a structured brief with summary, key findings, source table, and gaps
Sensible Extensions
Add a fact-checking loop. Before the Writer produces the final brief, run each factual claim through a second OpenRouter call with a prompt like "Verify this claim against the source text. Return TRUE/FALSE with a brief explanation." This catches hallucinated citations.
Persist to a vector store. Store scraped texts in ChromaDB (free, local) with sentence-transformer embeddings. The Writer can then retrieve only the most relevant chunks instead of cramming everything into context. This scales to 100+ sources. For a similar RAG pattern, see our Discord FAQ bot guide: /blog/discord-faq-bot-docs-rag-pinecone-free.
Add a browser-use loop for dynamic content. Some pages require JavaScript rendering or login. Extend the Searcher with Playwright's page.wait_for_selector patterns to handle SPAs. This mirrors patterns from our document extraction pipeline: /blog/ocr-it-llm-document-extraction-pipeline.
Swap in a local model. Replace the Hugging Face Inference API with Ollama running Mistral or Llama 3 locally. No rate limits, no cold starts. This is the exact stack we teach in FDE Coach's agent-building curriculum.
Common Pitfalls
OpenRouter rate limiting. Free models throttle aggressively. If you get 429 errors, add await asyncio.sleep(3) between Planner calls and consider caching query plans for repeated topics.
Hugging Face cold starts. The first inference call after a model has been idle takes 20-60 seconds. The 503 handling in our Writer code retries with the server's estimated wait time. Don't skip this—it's the difference between a working pipeline and mysterious timeouts.
Playwright selectors breaking. DuckDuckGo occasionally changes their DOM. If data-testid='result' stops working, fall back to a[data-testid='result-title-a'] directly or switch to Google Search (requires more sophisticated bot evasion).
Context window overflow. Free models have tight context limits (4k-8k tokens). If you scrape 3 results per query × 5 queries = 15 sources × 4000 chars each, you'll overflow. The MAX_SCRAPE_CHARS cap and Writer's prompt truncation prevent this, but watch for truncated outputs.
Non-deterministic JSON parsing. The Planner sometimes wraps its JSON in markdown fences or adds trailing commas. The strip logic in planner.py handles 90% of cases. For production, add a JSON repair library like json-repair.
FAQ
Q: Why not use a single LLM for everything? A: Separation of concerns. The Planner needs creativity, the Writer needs precision. Different models excel at different tasks. Plus, multi-agent architectures are easier to debug—you can inspect the intermediate query list and scraped texts independently.
Q: Can I run this without Playwright?
A: You could swap in requests + BeautifulSoup for static pages, but you'd lose JavaScript-rendered content and many modern sites. Playwright is worth the dependency. If you're building agentic systems professionally, browser automation is table stakes—see our breakdown of the FDE role: /blog/fde-weekly-workflow-breakdown.
Q: How do I deploy this as a service?
A: Wrap main.research() in a FastAPI endpoint, containerize with Docker, deploy on Hugging Face Spaces (free) or Railway (free tier). Add a simple HTML frontend. The async architecture means you can handle concurrent requests if you pool Playwright instances.
Q: What if I need more than 3 results per query?
A: Increase num_results in the Searcher call, but be aware: more results = more scraping time and larger context for the Writer. Start with 3, measure output quality, then scale up. You'll likely hit diminishing returns after 5 results per query.
Q: Is this production-ready? A: It's a solid prototype. For production, add proper logging (structlog), retry with exponential backoff on all HTTP calls, a result cache (Redis free tier or SQLite), and structured output validation on the Writer. FDE Coach covers these production patterns in depth.
Q: The brief quality is inconsistent. How do I improve it?
A: Upgrade the Writer model to meta-llama/Llama-3.1-8B-Instruct on a paid HF Inference tier ($0.06/hour) or run it locally with Ollama. Better prompts help too—add a section to the Writer prompt asking it to "cite specific sources for each claim using [1], [2] notation."
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