Building Autonomous Research Agents with Firecrawl & Brave Search

Production blueprint for building automated research agents using Firecrawl MCP, Brave Search API, and LLM synthesis with anti-bot resilience.

August 18, 2026
agentsblueprintsfirecrawlbrave-searchmcpresearch-agent
Building Autonomous Research Agents with Firecrawl & Brave Search

[!IMPORTANT] Production Context: Standard HTTP scrapers (requests, beautifulsoup4) fail on over 68% of modern web destinations due to Cloudflare Turnstile, perimeter bot heuristics, single-page application (SPA) client-side hydration, and dynamic DOM rendering. This blueprint presents an industrial-grade, anti-bot-resilient autonomous research agent architecture using the Brave Search API for independent discovery and Firecrawl MCP for clean markdown extraction and deep recursive scraping.


1. Architecture Overview: The 3-Tier Research Pipeline

Autonomous research agents solve a foundational bottleneck in LLM workflows: information latency and hallucination. A raw LLM cannot verify dynamic market movements, newly released whitepapers, API deprecations, or fragmented competitive intelligence without real-time web retrieval.

However, naive scraping pipelines flood LLM context windows with navigation menus, cookie banners, tracking scripts, and unstructured raw HTML, rapidly exhausting token budgets and inducing context rot.

The 3-Tier Research Pipeline decouples discovery, extraction, and synthesis into deterministic stages:

┌────────────────────────────────────────────────────────────────────────┐
│                         USER RESEARCH OBJECTIVE                        │
│            "Analyze enterprise pricing & latency SLAs of Vector DBs"   │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│                     TIER 1: DISCOVERY & TRIAGE                         │
│  • Query expansion & entity decomposition                              │
│  • Brave Search API (Independent web index, no Google/Bing bias)       │
│  • Domain authority filtering & URL deduplication                      │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ Ranked URL Candidates
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│                   TIER 2: RESILIENT EXTRACTION (MCP)                   │
│  • Firecrawl MCP Engine (`scrape_url`, `crawl_site`, `map_site`)       │
│  • Headless browser cluster + residential proxy rotation               │
│  • Dynamic JS execution + Shadow DOM / iframe traversal                │
│  • HTML-to-Clean-Markdown transformation (Noise stripped)             │
│  • Token pruning & strict citation regex pipelines                     │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │ Clean Markdown + Metadata
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│                 TIER 3: RECURSIVE SYNTHESIS & VERIFICATION             │
│  • Chunking & semantic embedding into Supabase / Pinecone              │
│  • Recursive claim extraction & cross-source corroboration            │
│  • Structured JSON synthesis + Markdown report with verifiable URLs    │
└───────────────────────────────────┬────────────────────────────────────┘
                                    │
                                    ▼
┌────────────────────────────────────────────────────────────────────────┐
│              AUTOMATED OUTPUTS & WEBHOOK NOTIFICATIONS                 │
│         Slack / Discord Webhooks • S3 Markdown Artifacts • DB Sync      │
└────────────────────────────────────────────────────────────────────────┘

Pipeline Stage Breakdown

TierComponentPrimary ResponsibilitySLA / Latency TargetOutput Artifact
Tier 1Brave Search APIGlobal query indexing, snippet triage, authority scoring150ms – 350msRanked JSON URL metadata
Tier 2Firecrawl MCPAnti-bot evasion, JS execution, noise-free Markdown extraction1.2s – 4.5sStructured Markdown + HTTP status
Tier 3LLM Synthesizer & Vector DBContext chunking, fact triangulation, citation mapping2.0s – 8.0sVerified Research Dossier

2. Overcoming Scraping Hurdles: Anti-Bot Resilience & Token Optimization

The Pitfalls of Naive Web Extraction

Traditional automated scrapers face four primary structural failures when gathering intelligence across the open web:

  1. Client-Side Hydration & SPAs: Modern platforms (React, Next.js, Vue, Angular) deliver near-empty HTML shells (<div id="__next"></div>). Simple HTTP GET clients receive zero indexable content.
  2. Bot Detection & CAPTCHAs: Sophisticated Web Application Firewalls (Cloudflare Turnstile, DataDome, Akamai) detect headless browser TLS fingerprints (JA3/JA4), canvas noise anomalies, and automated navigator flags.
  3. Context Window Token Bloat: Raw HTML pages routinely exceed 250,000 tokens due to inline SVG definitions, CSS bundles, JSON-LD schemas, and nested navigation trees. Feeding raw HTML directly to an LLM degrades reasoning accuracy and balloons API costs.
  4. Link Rot & Hallucinated Citations: Agents hallucinate synthetic links unless citations are bound to verified URLs during ingestion.
RAW WEB PAGE (250KB HTML / ~65,000 Tokens)
├── Scripts, Analytics, Pixel Trackers  ──► [ STRIPPED BY FIRECRAWL ]
├── SVG Icons, CSS Class Names          ──► [ STRIPPED BY FIRECRAWL ]
├── Navigation Menus & Cookie Banners   ──► [ STRIPPED BY FIRECRAWL ]
└── Core Semantic Article & Data Tables ──► CLEAN MDX (3.2KB / ~850 Tokens)
                                            (98.7% Token Reduction)

How Firecrawl MCP Solves Extraction at Scale

Firecrawl exposes an automated scraping, crawling, and search engine specifically architected for LLMs. When connected via the Model Context Protocol (MCP), agents invoke Firecrawl tools natively as isolated primitives:

  • firecrawl_scrape: Converts a single target URL into clean, structured Markdown, bypassing Cloudflare/Turnstile and rendering client-side JavaScript.
  • firecrawl_crawl: Asynchronously traverses sub-paths of a target domain up to a user-defined depth limit, adhering to robots.txt directives or custom inclusion regex.
  • firecrawl_map: Rapidly maps all accessible URLs within a domain without downloading page bodies, enabling instant URL discovery.

[!TIP] Firecrawl Infrastructure: If you are deploying production workloads handling thousands of crawls daily, sign up for Firecrawl or spin up your own instance. For managed enterprise deployments, Firecrawl offers high-throughput residential IP pools and automatic proxy rotation.


3. Complete Python & MCP Implementation

Below is the complete, production-grade asynchronous Python implementation of the Autonomous Research Agent. It integrates the Brave Search API, Firecrawl MCP, Claude 3.5 Sonnet (or any OpenAI-compatible LLM), and Supabase Vector Store.

System Architecture & Dependencies

pip install httpx mcp pydantic openai supabase python-dotenv rich

Configuration & Environment Setup

Create your .env configuration:

BRAVE_SEARCH_API_KEY=BSAx...
FIRECRAWL_API_KEY=fc-...
OPENAI_API_KEY=sk-...
SUPABASE_URL=https://xyzcompany.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...

Complete Source Code: autonomous_researcher.py

"""
Autonomous Research Agent with Firecrawl MCP & Brave Search API
Author: Prompt Genius Architecture Team
Date: 2026-08-19
"""

import asyncio
import os
import re
import json
import logging
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
import httpx
from openai import AsyncOpenAI
from supabase import create_client, Client

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("autonomous_researcher")

# ---------------------------------------------------------------------------
# Data Models
# ---------------------------------------------------------------------------

class SearchResultItem(BaseModel):
    title: str
    url: str
    description: str
    score: float = 0.0

class CrawledDocument(BaseModel):
    url: str
    title: Optional[str] = None
    markdown: str
    token_count: int
    metadata: Dict[str, Any] = Field(default_factory=dict)

class ResearchClaim(BaseModel):
    claim: str
    source_url: str
    confidence: float
    corroborated: bool = False

class ResearchDossier(BaseModel):
    topic: str
    executive_summary: str
    key_findings: List[str]
    claims: List[ResearchClaim]
    sources_consulted: List[str]
    generated_at: str

# ---------------------------------------------------------------------------
# Brave Search Discovery Engine
# ---------------------------------------------------------------------------

class BraveSearchEngine:
    """Handles independent web discovery using the Brave Search API."""
    
    BASE_URL = "https://api.search.brave.com/res/v1/web/search"

    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.getenv("BRAVE_SEARCH_API_KEY")
        if not self.api_key:
            raise ValueError("BRAVE_SEARCH_API_KEY is required.")
        self.client = httpx.AsyncClient(
            headers={
                "Accept": "application/json",
                "Accept-Encoding": "gzip",
                "X-Subscription-Token": self.api_key
            },
            timeout=10.0
        )

    async def search(self, query: str, count: int = 5) -> List[SearchResultItem]:
        logger.info(f"Executing Brave search for query: '{query}' (limit: {count})")
        params = {
            "q": query,
            "count": count,
            "text_decorations": False,
            "search_lang": "en"
        }
        try:
            response = await self.client.get(self.BASE_URL, params=params)
            response.raise_for_status()
            data = response.json()
            results = []
            
            web_results = data.get("web", {}).get("results", [])
            for item in web_results:
                results.append(SearchResultItem(
                    title=item.get("title", ""),
                    url=item.get("url", ""),
                    description=item.get("description", "")
                ))
            return results
        except Exception as e:
            logger.error(f"Brave Search API error: {e}")
            return []

    async def close(self):
        await self.client.aclose()

# ---------------------------------------------------------------------------
# Firecrawl MCP Client
# ---------------------------------------------------------------------------

class FirecrawlExtractor:
    """Connects to Firecrawl API / MCP server for headless JS scraping and clean MD extraction."""

    BASE_URL = "https://api.firecrawl.dev/v1"

    def __init__(self, api_key: Optional[str] = None):
        self.api_key = api_key or os.getenv("FIRECRAWL_API_KEY")
        if not self.api_key:
            raise ValueError("FIRECRAWL_API_KEY is required.")
        self.client = httpx.AsyncClient(
            headers={
                "Authorization": f"Bearer {self.api_key}",
                "Content-Type": "application/json"
            },
            timeout=45.0
        )

    @staticmethod
    def _prune_markdown_tokens(raw_markdown: str, max_chars: int = 18000) -> str:
        """
        Token Optimization Pipeline:
        1. Strips image URLs and redundant base64 data strings.
        2. Normalizes excessive line breaks.
        3. Caps characters to stay within tight LLM context budgets.
        """
        # Remove base64 images and large media links
        cleaned = re.sub(r"![.*?](data:image/.*?)", "", raw_markdown)
        cleaned = re.sub(r"![.*?](https?://.*?)", "", cleaned)
        # Collapse multiple empty lines
        cleaned = re.sub(r"
{3,}", "

", cleaned)
        
        if len(cleaned) > max_chars:
            cleaned = cleaned[:max_chars] + "

... [Content truncated for context limits] ..."
            
        return cleaned.strip()

    async def scrape_url(self, url: str) -> Optional[CrawledDocument]:
        logger.info(f"Extracting clean markdown via Firecrawl: {url}")
        payload = {
            "url": url,
            "pageOptions": {
                "onlyMainContent": True,
                "includeHtml": False
            }
        }
        try:
            response = await self.client.post(f"{self.BASE_URL}/scrape", json=payload)
            if response.status_code != 200:
                logger.warning(f"Firecrawl scrape failed for {url} with status {response.status_code}: {response.text}")
                return None
            
            data = response.json()
            if not data.get("success", False):
                return None
                
            page_data = data.get("data", {})
            raw_md = page_data.get("markdown", "")
            pruned_md = self._prune_markdown_tokens(raw_md)
            
            # Approximate token count (1 token ≈ 4 chars)
            est_tokens = len(pruned_md) // 4

            return CrawledDocument(
                url=url,
                title=page_data.get("metadata", {}).get("title"),
                markdown=pruned_md,
                token_count=est_tokens,
                metadata=page_data.get("metadata", {})
            )
        except Exception as e:
            logger.error(f"Error scraping {url} with Firecrawl: {e}")
            return None

    async def close(self):
        await self.client.aclose()

# ---------------------------------------------------------------------------
# Tool Gating & Triage Logic
# ---------------------------------------------------------------------------

class ResearchOrchestrator:
    """Coordinates search query generation, tool gating, synthesis, and persistence."""

    def __init__(self):
        self.brave = BraveSearchEngine()
        self.firecrawl = FirecrawlExtractor()
        self.openai = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
        
        # Initialize Supabase Client
        supabase_url = os.getenv("SUPABASE_URL")
        supabase_key = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
        self.supabase: Optional[Client] = (
            create_client(supabase_url, supabase_key)
            if supabase_url and supabase_key
            else None
        )

    async def generate_search_queries(self, objective: str) -> List[str]:
        """Decomposes an abstract research objective into 3 distinct atomic search queries."""
        prompt = f"""You are an autonomous research planner.
Objective: "{objective}"

Decompose this objective into exactly 3 diverse, high-signal search queries optimized for a search engine.
Avoid Boolean syntax (AND/OR). Return ONLY a JSON list of strings.

Example:
["vector database benchmarks latency 2026", "qdrant pinecone milvus pricing comparison", "weaviate enterprise sla downtime analysis"]
"""
        response = await self.openai.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"}
        )
        content = response.choices[0].message.content or "{}"
        parsed = json.loads(content)
        queries = parsed.get("queries", list(parsed.values())[0] if parsed else [])
        if not isinstance(queries, list):
            queries = [objective]
        return queries[:3]

    async def triage_and_extract(self, search_results: List[SearchResultItem], max_documents: int = 4) -> List[CrawledDocument]:
        """
        Tool Gating Logic:
        Evaluates search snippets and filters out low-signal domains (e.g., social feeds, login walls).
        Executes parallel Firecrawl extractions.
        """
        # Deduplicate URLs
        unique_urls = list({item.url: item for item in search_results}.values())
        
        # Filter obvious non-content URLs
        filtered_candidates = [
            item for item in unique_urls
            if not any(blocked in item.url.lower() for blocked in ["twitter.com", "facebook.com", "login", "signup", ".pdf"])
        ]
        
        target_items = filtered_candidates[:max_documents]
        logger.info(f"Targeting {len(target_items)} verified URLs for Firecrawl extraction.")
        
        # Parallel extraction with asyncio.gather
        tasks = [self.firecrawl.scrape_url(item.url) for item in target_items]
        scraped_docs = await asyncio.gather(*tasks)
        
        return [doc for doc in scraped_docs if doc is not None]

    async def synthesize_dossier(self, objective: str, documents: List[CrawledDocument]) -> ResearchDossier:
        """Performs recursive multi-document synthesis with strict citation tracking."""
        context_blocks = []
        for idx, doc in enumerate(documents, start=1):
            context_blocks.append(
                f"--- SOURCE [{idx}] ---
"
                f"URL: {doc.url}
"
                f"TITLE: {doc.title or 'Unknown'}
"
                f"CONTENT:
{doc.markdown}
"
            )
        
        aggregated_context = "

".join(context_blocks)
        
        system_prompt = (
            "You are a Principal Intelligence Analyst. Your mission is to produce a verifiable, "
            "fact-checked Research Dossier based exclusively on the provided source materials.
"
            "Rules:
"
            "1. Every single key claim MUST be attributed to a specific source URL from the context.
"
            "2. Never hallucinate facts or extrapolate beyond provided evidence.
"
            "3. Structure output strictly according to the specified JSON schema."
        )
        
        user_prompt = f"""RESEARCH OBJECTIVE:
{objective}

AVAILABLE PRIMARY SOURCES:
{aggregated_context}

Respond in the following JSON format:
{{
  "topic": "{objective}",
  "executive_summary": "High-level summary of the findings...",
  "key_findings": ["Finding 1 with concrete metrics", "Finding 2..."],
  "claims": [
    {{
      "claim": "Specific factual proposition",
      "source_url": "https://source.com/page",
      "confidence": 0.95,
      "corroborated": true
    }}
  ],
  "sources_consulted": ["https://..."]
}}
"""
        response = await self.openai.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            response_format={"type": "json_object"}
        )
        
        raw_json = json.loads(response.choices[0].message.content or "{}")
        raw_json["generated_at"] = "2026-08-19T00:00:00Z"
        return ResearchDossier(**raw_json)

    async def persist_and_notify(self, dossier: ResearchDossier):
        """Persists dossier to Supabase and dispatches webhook notifications."""
        # 1. Supabase Persistence (if configured)
        if self.supabase:
            try:
                self.supabase.table("research_dossiers").insert({
                    "topic": dossier.topic,
                    "executive_summary": dossier.executive_summary,
                    "payload": dossier.model_dump(),
                    "created_at": dossier.generated_at
                }).execute()
                logger.info("Dossier successfully saved to Supabase.")
            except Exception as e:
                logger.error(f"Supabase persistence failed: {e}")

        # 2. Discord Webhook Dispatch (if configured)
        webhook_url = os.getenv("DISCORD_WEBHOOK_URL")
        if webhook_url:
            async with httpx.AsyncClient() as client:
                embed = {
                    "title": f"🔬 Research Completed: {dossier.topic}",
                    "description": dossier.executive_summary[:1800],
                    "color": 3447003,
                    "fields": [
                        {"name": "Sources Consulted", "value": str(len(dossier.sources_consulted)), "inline": True},
                        {"name": "Verified Claims", "value": str(len(dossier.claims)), "inline": True}
                    ]
                }
                await client.post(webhook_url, json={"embeds": [embed]})
                logger.info("Webhook notification sent.")

    async def run(self, objective: str) -> ResearchDossier:
        try:
            # Step 1: Query generation
            queries = await self.generate_search_queries(objective)
            
            # Step 2: Multi-query Brave Search
            search_tasks = [self.brave.search(q, count=4) for q in queries]
            search_results_nested = await asyncio.gather(*search_tasks)
            flat_results = [item for sublist in search_results_nested for item in sublist]
            
            # Step 3: Triage & Firecrawl extraction
            crawled_docs = await self.triage_and_extract(flat_results, max_documents=5)
            if not crawled_docs:
                raise RuntimeError("No documents were successfully extracted.")
                
            # Step 4: LLM Synthesis
            dossier = await self.synthesize_dossier(objective, crawled_docs)
            
            # Step 5: Save & Broadcast
            await self.persist_and_notify(dossier)
            
            return dossier
            
        finally:
            await self.brave.close()
            await self.firecrawl.close()

# ---------------------------------------------------------------------------
# CLI Execution Entrypoint
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    async def main():
        agent = ResearchOrchestrator()
        objective = "Compare current 2026 latency benchmarks and cost per million tokens for Pinecone vs Qdrant Cloud"
        print(f"[*] Starting autonomous research on: '{objective}'")
        dossier = await agent.run(objective)
        print("
" + "="*80)
        print(f"EXECUTIVE SUMMARY:
{dossier.executive_summary}
")
        print("KEY FINDINGS:")
        for idx, finding in enumerate(dossier.key_findings, 1):
            print(f"  {idx}. {finding}")
        print("
VERIFIED SOURCES:")
        for s in dossier.sources_consulted:
            print(f"  - {s}")
        print("="*80)

    asyncio.run(main())

4. Token Pruning & Citation Extraction Regex Pipeline

To guarantee that citations are not hallucinated by the synthesis layer, use a deterministic regex post-processor that verifies all generated claims against the raw extracted URLs:

import re
from typing import List, Tuple

def validate_citations(dossier_claims: List[dict], extracted_urls: List[str]) -> List[Tuple[dict, bool]]:
    """
    Cross-checks all cited URLs against the verified extraction pool.
    Flags any synthetic or mutated URLs generated by the LLM.
    """
    verified_url_set = set(extracted_urls)
    results = []

    for item in dossier_claims:
        claimed_url = item.get("source_url", "")
        # Exact match check
        if claimed_url in verified_url_set:
            results.append((item, True))
        else:
            # Fuzzy origin check (domain match)
            domain_match = any(
                re.search(re.escape(claimed_url.split("//")[-1].split("/")[0]), v_url)
                for v_url in verified_url_set
            )
            results.append((item, domain_match))

    return results

5. Production Deployment & Automation

To convert this autonomous agent from a local script into a 24/7 background research daemon, deploy the workflow on automated serverless infrastructure.

Option A: Modal Serverless Cron Job

Modal allows you to run asynchronous Python agent pipelines on a scheduled cron without managing persistent virtual machines.

# modal_agent_cron.py
import modal
from autonomous_researcher import ResearchOrchestrator

app = modal.App("firecrawl-research-agent")

image = (
    modal.Image.debian_slim()
    .pip_install("httpx", "openai", "supabase", "pydantic", "rich")
)

@app.function(
    image=image,
    schedule=modal.Cron("0 8 * * 1"), # Runs every Monday at 8:00 AM UTC
    secrets=[modal.Secret.from_name("research-agent-secrets")],
    timeout=600
)
async def scheduled_weekly_research():
    topics = [
        "State of AI Agent Frameworks (LangGraph vs CrewAI vs OpenAI SDK) in 2026",
        "Recent Breakthroughs in Prompt Injection Mitigation and Guardrail Architecture"
    ]
    agent = ResearchOrchestrator()
    for topic in topics:
        dossier = await agent.run(topic)
        print(f"Generated dossier for '{topic}': {len(dossier.claims)} verified claims.")

Option B: Railway Container Deployment

Railway offers one-click Docker container hosting with persistent environment variable injection and automatic health checks.

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY autonomous_researcher.py .
COPY server.py .

EXPOSE 8080
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8080"]

6. Architecture Comparison: Search & Scrape Providers

CapabilityBrave Search + Firecrawl MCPGoogle Custom Search + BeautifulSoupPerplexity Online API
Independent Web IndexYes (Brave 100% self-indexed)No (Google indexed, restricted API)No (Aggregated search index)
JS Rendering / SPA SupportFull Headless ChromeNone (Raw static HTML only)Handled server-side
Anti-Bot BypassBuilt-in Cloudflare BypassZero (Blocked on 403/503)Handled server-side
Context Token EfficiencyHigh (Clean semantic Markdown)Very Low (Messy DOM tree)High (Pre-synthesized answers)
Raw Evidence InspectionFull access to source MDAccess to raw HTMLBlack-box generated summary
Cost per 1,000 Invocations~$5.00~$10.00 + proxy infrastructure~$20.00+

Building high-throughput autonomous agents requires reliable infrastructure partners with generous developer tiers and partner revenue sharing:

  • Web Extraction: Firecrawl.dev — The gold standard for LLM-native web scraping, deep crawling, and MCP integration.
  • Compute & Deployment: Railway.com — Zero-devops cloud deployment with instant PostgreSQL and Redis provisioning.
  • Vector & Relational Storage: Supabase — Open-source Firebase alternative with built-in pgvector extension for storing semantic embeddings and research dossiers.

[!TIP] Production Scaling Tip: When running recursive deep-crawls across multi-page documentation hubs, set limit: 20 and maxDepth: 2 in Firecrawl to prevent scraping infinite pagination loops and consuming excess credits.