Guide #4 Use Case — Research 25 min read March 2026

The AI Research Analyst: 50 Websites Overnight, Report by Morning

Your team needs a competitive analysis. A human analyst opens 50 tabs, reads for two days, copies findings into a doc, formats citations, and delivers a report that's already stale. An AI agent does the same work overnight for $2.50.

This guide shows you how: 50 browser containers running in parallel, each visiting a real website with a real Chrome instance, extracting structured data via CDP. The agent cross-references everything in a terminal sandbox and delivers a finished Markdown report with citations before your morning coffee.

What this replaces

Every company has some version of this workflow: someone needs to research 30, 50, or 100 websites and produce a structured report. Competitive analysis, vendor evaluation, market mapping, regulatory research, recruiting sourcing, lead qualification. The shape is always the same — visit a list of URLs, read what's there, extract the relevant data, and synthesize findings.

The human version of this is slow, expensive, and boring. An analyst opens tabs, reads pages, copies text into a spreadsheet, follows links to secondary sources, loses their place, takes a break, forgets which sites they already checked, and eventually produces a report after a day or two. If the list is long enough, they skip some sites entirely and hope nobody notices.

Human analyst, 50 websites

  • 12–16 hours of focused work over 2 days
  • Analyst costs $60–120/hour fully loaded
  • Inconsistent depth — first 10 sites get careful reading, last 10 get skimmed
  • Manual copy-paste into Google Docs or Excel
  • Citations are "I think it was on their pricing page"
  • Report is stale before it's delivered
  • Cannot rerun — if scope changes, start over

AI agent, 50 websites

  • 15–25 minutes wall clock (parallel execution)
  • $2.50 total compute + model costs
  • Every site gets identical treatment — same depth, same extraction schema
  • Structured JSON output, auto-compiled into Markdown
  • Every finding links to the exact URL and timestamp
  • Rerun weekly, daily, or on-demand — the brief is a script
  • Scales to 500 sites without changing the approach
$2.50
Total cost per run
~20 min
Wall clock time
2 days
Human time replaced

How it works: the architecture

The agent uses two types of compute, working together. Browser containers do the reading — each one is a real Chrome instance running in its own isolated cloud container, controllable via the Chrome DevTools Protocol (CDP). A terminal sandbox does the thinking — it's where the agent runs Python, processes the extracted data, and compiles the final report.

This is the key insight: browsers for input, terminal for processing. The same pattern a human researcher uses (Chrome for reading, a text editor for writing), but parallelized across 50 machines.

Research Brief + 50 URLs
50 browser containers (parallel)
Chrome #1
competitor-a.com
Chrome #2
competitor-b.com
Chrome #3
competitor-c.com
Chrome #50
competitor-z.com
Each browser: navigate → read → follow links → extract → return JSON
↓ 50 JSON extraction results
Terminal Sandbox (ab0t.medium)
Cross-reference → deduplicate → rank findings → compile Markdown → email report

Why browser containers, not web scraping?

Traditional scraping (requests.get(), Scrapy, BeautifulSoup) breaks on any site that requires JavaScript rendering, login flows, cookie consent dialogs, infinite scroll, or client-side routing. That's most of the modern web.

Browser containers give the agent a real Chrome instance that renders JavaScript, handles cookies, executes client-side apps, and sees the page exactly as a human would. The agent controls it via CDP — the same protocol that Playwright, Puppeteer, and Chrome DevTools use. It can click, scroll, wait for elements, take screenshots, and extract rendered DOM content.

The browser container is not a headless scraper pretending to be a browser. It's an actual browser, running in its own container with its own IP address, its own cookie jar, and its own session state. If a site requires a login, the agent can type credentials. If a site has a CAPTCHA, the agent can use Claude's vision to solve it. If a site loads content dynamically on scroll, the agent scrolls.

Each browser is isolated

Every browser container has its own cookies, localStorage, session state, and IP address. Site A cannot see what the agent did on Site B. There's no cookie leakage, no cross-site tracking, and no shared state. This is critical for competitive research where you don't want sites detecting that the same entity is visiting all of them.

Why a terminal sandbox for the report?

The browser containers are good at reading web pages. They're not good at processing 50 JSON files, deduplicating findings, ranking by relevance, and rendering a polished Markdown report. That's a Python job.

The terminal sandbox gives the agent a full Linux machine with pip install, file I/O, and persistent storage. It runs a Python script (or Claude Code, or any other agent) to take the 50 extraction results and synthesize them into a single deliverable. It can install jinja2 for templating, pandas for data manipulation, or matplotlib for charts. It has the full filesystem and full root access.

Step by step: building the research agent

This section walks through the complete implementation. You'll create a terminal sandbox as the orchestrator, launch 50 browser containers in parallel, extract structured data from each site, and compile the report.

Step 1: Create the orchestrator sandbox

The terminal sandbox is the agent's workstation. It orchestrates the browser fleet, processes the results, and stores the final report. Create a ab0t.medium — it only needs modest compute since the heavy lifting (browser rendering) happens in the containers.

bash
# Create the orchestrator sandbox
export SANDBOX_API_KEY="ab0t_sk_live_YOUR_KEY_HERE"
export SANDBOX_URL="https://sandbox.dev.ab0t.com"

SANDBOX=$(curl -s -X POST "$SANDBOX_URL/api/sandboxes" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "research-orchestrator",
    "instance_type": "ab0t.medium",
    "auto_stop_minutes": 120
  }')

SANDBOX_ID=$(echo "$SANDBOX" | jq -r '.sandbox_id')
echo "Sandbox: $SANDBOX_ID"
echo "Status:  $(echo $SANDBOX | jq -r '.status')"
echo "IP:      $(echo $SANDBOX | jq -r '.instance_ip')"
echo "Cost:    $(echo $SANDBOX | jq -r '.hourly_cost')/hour"

Wait for the sandbox to reach running status. This typically takes 30–60 seconds as the EC2 instance boots and the manager agent initializes.

bash
# Poll until ready
while true; do
  STATUS=$(curl -s "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID" \
    -H "Authorization: Bearer $SANDBOX_API_KEY" | jq -r '.status')
  echo "Status: $STATUS"
  [ "$STATUS" = "running" ] && break
  sleep 5
done

Step 2: Upload the research brief and URL list

The research brief tells the agent what to look for. The URL list tells it where to look. Save both as files to the sandbox's /workspace directory.

bash
# Upload the research brief
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID/files" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "research_brief.md",
    "content": "# Competitive Analysis: Cloud IDE Market\n\nResearch the following companies. For each, extract:\n\n1. **Pricing** — tiers, per-seat cost, free tier limits\n2. **Key features** — what they emphasize on their homepage and product page\n3. **Target audience** — enterprise, startup, indie developer, student\n4. **Recent changes** — any pricing changes, new features, or pivots in the last 6 months\n5. **Integration ecosystem** — what tools, languages, and platforms they support\n\nFor each finding, record the exact URL where the information was found.\n\nOutput format: JSON with one object per company."
  }'
bash
# Upload the URL list
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID/files" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "filename": "urls.json",
    "content": "[\n  {\"company\": \"Replit\", \"urls\": [\"https://replit.com\", \"https://replit.com/pricing\"]},\n  {\"company\": \"Gitpod\", \"urls\": [\"https://gitpod.io\", \"https://gitpod.io/pricing\"]},\n  {\"company\": \"GitHub Codespaces\", \"urls\": [\"https://github.com/features/codespaces\", \"https://docs.github.com/en/billing/managing-billing-for-github-codespaces\"]},\n  {\"company\": \"CodeSandbox\", \"urls\": [\"https://codesandbox.io\", \"https://codesandbox.io/pricing\"]},\n  {\"company\": \"StackBlitz\", \"urls\": [\"https://stackblitz.com\", \"https://stackblitz.com/pricing\"]}\n]"
  }'
This example uses 5 companies with 10 URLs for clarity

The same approach works for 50 companies with 100+ URLs. The only thing that changes is the parallelism. Each browser container handles one company's URLs sequentially, and all containers run in parallel.

Step 3: Upload the orchestrator script

This is the main script that runs inside the terminal sandbox. It reads the URL list, launches browser containers in parallel, waits for results, and compiles the report. The key pattern: fan out to browsers, fan in to the terminal.

python — orchestrator.py
import asyncio
import json
import os
import time
from datetime import datetime
import httpx

# Configuration
SANDBOX_URL = os.environ["SANDBOX_URL"]
API_KEY = os.environ["SANDBOX_API_KEY"]
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}


async def create_browser(client: httpx.AsyncClient) -> dict:
    """Create a Chrome browser container with CDP enabled."""
    resp = await client.post(
        f"{SANDBOX_URL}/api/browsers",
        headers=HEADERS,
        json={
            "browser_type": "chrome",
            "enable_debug_port": True,
            "idle_timeout_minutes": 15,
        },
    )
    resp.raise_for_status()
    return resp.json()


async def wait_for_container(client, container_id: str, timeout=120) -> dict:
    """Poll container status until assigned or timeout."""
    start = time.time()
    while time.time() - start < timeout:
        resp = await client.get(
            f"{SANDBOX_URL}/api/containers/{container_id}/status",
            headers=HEADERS,
        )
        data = resp.json()
        if data["state"] == "assigned":
            return data
        if data["state"] == "failed":
            raise RuntimeError(f"Container {container_id} failed")
        await asyncio.sleep(3)
    raise TimeoutError(f"Container {container_id} not ready after {timeout}s")


async def release_container(client, container_id: str):
    """Release container back to the warm pool."""
    try:
        await client.post(
            f"{SANDBOX_URL}/api/containers/{container_id}/release",
            headers=HEADERS,
        )
    except Exception:
        pass  # Best effort — cleanup loop handles stuck containers


async def research_company(client, company: dict, brief: str) -> dict:
    """
    Research a single company:
    1. Create a browser container
    2. Navigate to each URL, extract page content
    3. Return structured findings
    """
    company_name = company["company"]
    urls = company["urls"]
    print(f"[{company_name}] Starting research ({len(urls)} URLs)...")

    # Create browser
    browser = await create_browser(client)
    container_id = browser["container_id"]

    try:
        # Wait for browser to be ready
        status = await wait_for_container(client, container_id)
        cdp_url = status.get("cdp_url")
        print(f"[{company_name}] Browser ready: {container_id}")

        # Visit each URL and extract content
        page_data = []
        for url in urls:
            print(f"[{company_name}] Visiting: {url}")
            # Use CDP to navigate and extract
            # In production, use Playwright or Puppeteer connected to cdp_url
            # Here we show the extraction pattern:
            page_data.append({
                "url": url,
                "visited_at": datetime.utcnow().isoformat(),
                "content": "[extracted via CDP — see CDP section below]",
            })

        return {
            "company": company_name,
            "status": "success",
            "pages_visited": len(page_data),
            "pages": page_data,
            "container_id": container_id,
        }

    except Exception as e:
        print(f"[{company_name}] Error: {e}")
        return {
            "company": company_name,
            "status": "error",
            "error": str(e),
            "container_id": container_id,
        }

    finally:
        # Always release the browser back to the pool
        await release_container(client, container_id)
        print(f"[{company_name}] Browser released.")


async def main():
    # Load inputs
    with open("urls.json") as f:
        companies = json.load(f)
    with open("research_brief.md") as f:
        brief = f.read()

    print(f"Researching {len(companies)} companies...")
    start_time = time.time()

    # Fan out: research all companies in parallel
    async with httpx.AsyncClient(timeout=300) as client:
        tasks = [research_company(client, c, brief) for c in companies]
        results = await asyncio.gather(*tasks, return_exceptions=True)

    # Fan in: collect results
    successes = [r for r in results if isinstance(r, dict) and r["status"] == "success"]
    failures = [r for r in results if isinstance(r, dict) and r["status"] == "error"]
    exceptions = [r for r in results if isinstance(r, Exception)]

    elapsed = time.time() - start_time

    # Save raw results
    with open("results_raw.json", "w") as f:
        json.dump(successes, f, indent=2)

    # Compile the report
    report = compile_report(successes, failures, brief, elapsed)
    with open("report.md", "w") as f:
        f.write(report)

    print(f"\n{'='*60}")
    print(f"Research complete in {elapsed:.0f}s")
    print(f"  Succeeded: {len(successes)}/{len(companies)}")
    print(f"  Failed:    {len(failures)}")
    print(f"  Errors:    {len(exceptions)}")
    print(f"  Report:    /workspace/report.md")
    print(f"{'='*60}")


def compile_report(results, failures, brief, elapsed) -> str:
    """Compile extraction results into a Markdown report."""
    lines = [
        f"# Competitive Analysis Report",
        f"",
        f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}",
        f"Companies analyzed: {len(results)}",
        f"Total time: {elapsed:.0f} seconds",
        f"",
        f"---",
        f"",
    ]

    for result in results:
        lines.append(f"## {result['company']}")
        lines.append(f"")
        lines.append(f"Pages visited: {result['pages_visited']}")
        lines.append(f"")
        for page in result["pages"]:
            lines.append(f"- [{page['url']}]({page['url']}) (visited {page['visited_at']})")
        lines.append(f"")
        # In production, include the structured extraction here:
        # pricing, features, target audience, recent changes, etc.
        lines.append(f"")

    if failures:
        lines.append(f"## Failures")
        lines.append(f"")
        for f in failures:
            lines.append(f"- **{f['company']}**: {f['error']}")
        lines.append(f"")

    return "\n".join(lines)


if __name__ == "__main__":
    asyncio.run(main())

Upload this script to the sandbox:

bash
# Upload the orchestrator script
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID/files" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- << 'EOF'
{
  "filename": "orchestrator.py",
  "content": "... (the script above)"
}
EOF

Step 4: Run the research

Execute the orchestrator on the sandbox. It fans out to 50 browser containers in parallel, waits for all of them to finish, and compiles the report.

bash
# Install dependencies and run
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID/execute" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "command": "cd /workspace && pip install httpx && SANDBOX_URL=https://sandbox.dev.ab0t.com SANDBOX_API_KEY=ab0t_sk_live_YOUR_KEY python orchestrator.py"
  }' | jq .

# Response:
# {
#   "stdout": "Researching 50 companies...\n[Replit] Starting research (2 URLs)...\n[Gitpod] Starting research (2 URLs)...\n...\n============================================================\nResearch complete in 847s\n  Succeeded: 48/50\n  Failed:    2\n  Report:    /workspace/report.md\n============================================================",
#   "stderr": "",
#   "exit_code": 0,
#   "execution_time_ms": 854230
# }

Step 5: Download the report

The report is a Markdown file on the sandbox's filesystem. Download it, or download the entire workspace as a tarball to get the raw JSON alongside the compiled report.

bash
# Download the entire workspace (report + raw data)
curl -s "$SANDBOX_URL/api/sandboxes/$SANDBOX_ID/download" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -o research-output.tar.gz

tar xzf research-output.tar.gz
cat workspace/report.md
That's the complete flow.

Terminal sandbox as orchestrator. Browser containers for parallel web reading. Fan out, fan in, compile, deliver. The report is in /workspace/report.md. The raw extraction data is in /workspace/results_raw.json for further processing.

Deep dive: extracting data with CDP

The orchestrator above connects to browser containers via the Chrome DevTools Protocol. This section shows how the extraction actually works inside each browser — navigating pages, waiting for content, and pulling structured data from the rendered DOM.

Connecting Playwright to a browser container

Each browser container returns a cdp_url when it becomes assigned. Connect to it with Playwright (or Puppeteer, or any CDP client):

python — extract.py
from playwright.async_api import async_playwright

async def extract_page(cdp_url: str, url: str) -> dict:
    """Navigate to a URL and extract structured data."""
    async with async_playwright() as p:
        # Connect to the remote browser via CDP
        browser = await p.chromium.connect_over_cdp(cdp_url)
        context = browser.contexts[0]
        page = context.pages[0] if context.pages else await context.new_page()

        # Navigate to the target URL
        await page.goto(url, wait_until="networkidle", timeout=30000)

        # Wait for dynamic content to render
        await page.wait_for_timeout(2000)

        # Extract the full page text (rendered, not source HTML)
        title = await page.title()
        text_content = await page.evaluate("() => document.body.innerText")
        meta_desc = await page.evaluate("""() => {
            const el = document.querySelector('meta[name=\"description\"]');
            return el ? el.content : '';
        }""")

        # Extract all links for follow-up
        links = await page.evaluate("""() => {
            return Array.from(document.querySelectorAll('a[href]'))
                .map(a => ({text: a.innerText.trim(), href: a.href}))
                .filter(l => l.text && l.href.startsWith('http'))
                .slice(0, 50);
        }""")

        # Take a screenshot for visual reference
        screenshot = await page.screenshot(full_page=False)

        return {
            "url": url,
            "title": title,
            "meta_description": meta_desc,
            "text_content": text_content[:10000],  # Cap at 10k chars
            "links": links,
            "screenshot_bytes": len(screenshot),
        }

Handling common obstacles

Real websites have cookie banners, login walls, infinite scroll, and client-side rendering. The browser container handles all of this because it's a real browser. Here are the patterns for the most common obstacles:

Cookie consent dialogs

python
# Dismiss cookie banners — try common selectors
cookie_selectors = [
    "button:has-text('Accept')",
    "button:has-text('Accept all')",
    "button:has-text('I agree')",
    "button:has-text('Got it')",
    "[id*='cookie'] button",
    "[class*='cookie'] button",
    "[class*='consent'] button",
]
for selector in cookie_selectors:
    try:
        btn = page.locator(selector).first
        if await btn.is_visible(timeout=2000):
            await btn.click()
            break
    except:
        continue

Infinite scroll / lazy loading

python
# Scroll to load all content (pricing tables, feature lists)
previous_height = 0
for _ in range(10):  # Max 10 scroll iterations
    current_height = await page.evaluate("document.body.scrollHeight")
    if current_height == previous_height:
        break
    await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
    await page.wait_for_timeout(1500)
    previous_height = current_height

Pricing tables with complex DOM structure

python
# Extract structured pricing data from pricing pages
pricing_data = await page.evaluate("""() => {
    // Look for common pricing card patterns
    const cards = document.querySelectorAll(
        '[class*="pricing"], [class*="plan"], [data-plan]'
    );
    return Array.from(cards).map(card => ({
        name: card.querySelector('h2, h3, [class*="name"]')?.innerText || '',
        price: card.querySelector('[class*="price"], [class*="amount"]')?.innerText || '',
        features: Array.from(card.querySelectorAll('li, [class*="feature"]'))
            .map(f => f.innerText.trim())
            .filter(Boolean),
    }));
}""")

Using Claude's vision for pages that resist DOM extraction

Some pages use canvas rendering, complex CSS layouts, or image-based pricing tables that DOM queries can't parse. For these, take a screenshot and send it to Claude's vision model for extraction:

python
import anthropic
import base64

async def extract_with_vision(page, extraction_prompt: str) -> str:
    """Screenshot the page and use Claude's vision to extract data."""
    screenshot = await page.screenshot(full_page=True)
    b64 = base64.b64encode(screenshot).decode()

    client = anthropic.Anthropic()
    response = client.messages.create(
        model="claude-sonnet-4-6-20250514",
        max_tokens=4096,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": "image/png",
                        "data": b64,
                    },
                },
                {
                    "type": "text",
                    "text": extraction_prompt,
                },
            ],
        }],
    )
    return response.content[0].text
DOM extraction vs vision extraction

Use DOM extraction (JavaScript evaluate) as the default — it's faster, cheaper, and returns structured text. Fall back to vision extraction for pages where the DOM doesn't contain the visible text: canvas-rendered content, image-heavy pricing cards, PDF viewers embedded in the page, or complex interactive widgets.

Fast startup with warm pools

Without warm pools, creating 50 browser containers takes 30–90 seconds each as cloud containers provisions the tasks. That's fine when they're created in parallel (the wall clock time is still ~90 seconds), but you're paying for idle containers while the last ones spin up.

With warm pools enabled, the platform maintains a fleet of pre-provisioned browser containers in a ready state. When you call POST /api/browsers, the platform claims one from the pool immediately — no container provisioning delay. The browser is already running. The container transitions from ready to assigned in milliseconds.

POST /api/browsers Warm pool: 20 ready Claimed in <1s No provisioning delay. The browser was already running.

When the research task finishes, the orchestrator calls POST /api/containers/{id}/release instead of deleting the container. This returns it to the warm pool: the container is cleaned (kill all processes, clear cookies, wipe temp files), transitioned to ready, and made available for the next claim. The container keeps running — no cold start for the next user.

Pool sizing for research workloads

If you run the 50-site research daily, you need to balance pool cost against startup speed:

Pool sizeFirst 50 containers claimedIdle pool cost / hourWhen to use
0 (no pool)~90s (parallel cold-start)$0Occasional research, cost-sensitive
10~60s (10 instant, 40 cloud)~$0.30Daily research with modest parallelism
25~45s (25 instant, 25 cloud)~$0.75Multiple daily runs
50<5s (all instant)~$1.50On-demand research with latency SLA
Pool containers auto-recycle

Warm pool containers have a maximum age (default: 1 hour). The platform's background cleanup loop terminates containers that have been in the pool too long and provisions fresh replacements. This prevents stale browser state from accumulating and keeps memory usage bounded.

Scaling: from 50 sites to 500

The architecture doesn't change when the list grows. 50 sites or 500, the pattern is the same: create one browser per company (or per URL, for maximum parallelism), fan out, fan in.

Concurrency control

Launching 500 browser containers simultaneously would spike your concurrency limit. Use a semaphore to cap concurrent requests:

python
import asyncio

# Limit to 50 concurrent browser containers
CONCURRENCY = 50
semaphore = asyncio.Semaphore(CONCURRENCY)

async def research_with_limit(client, company, brief):
    async with semaphore:
        return await research_company(client, company, brief)

# Fan out with concurrency limit
tasks = [research_with_limit(client, c, brief) for c in companies]
results = await asyncio.gather(*tasks, return_exceptions=True)

With a concurrency limit of 50 and 500 companies, the orchestrator processes them in batches of 50. Each batch takes ~15 minutes (90 seconds for browser provisioning + ~12 minutes for extraction). Total wall clock: ~150 minutes. Total cost: ~$25.

One browser per URL vs one browser per company

StrategyBrowsers createdSpeedCostIsolation
One per company 50 ~15 min (URLs visited sequentially within each browser) Lower — fewer containers Cookies shared across URLs for the same company
One per URL 100+ ~5 min (all URLs in parallel) Higher — more containers Full isolation between every page visit

For competitive research, one browser per company is usually the right choice. The browser maintains cookies and session state across the company's pages (useful for navigating from homepage to pricing page), and the sequential URL visits within a company are fast (sub-second navigations).

For security audits or privacy research where you need full isolation between every URL, use one browser per URL.

Handling failures gracefully

When you're visiting 50 websites in parallel, some will fail. Pages time out. Sites go down. CAPTCHAs block automation. Cloudflare challenges trigger. The orchestrator needs to handle all of this without losing the successful results.

The accept-partial-success mindset

If 48 out of 50 sites succeed, that's a successful research run. Don't retry the entire batch because two sites failed. Log the failures, include them in the report, and let the human decide whether to retry those two manually or skip them.

python
# asyncio.gather with return_exceptions=True
# This ensures one failure doesn't cancel the other 49 tasks
results = await asyncio.gather(*tasks, return_exceptions=True)

# Separate successes from failures
successes = []
failures = []
for r in results:
    if isinstance(r, Exception):
        failures.append({"error": str(r), "type": type(r).__name__})
    elif r.get("status") == "error":
        failures.append(r)
    else:
        successes.append(r)

print(f"Succeeded: {len(successes)}, Failed: {len(failures)}")
# Continue with successes — don't throw away good data

Retry with backoff for transient failures

python
async def research_with_retry(client, company, brief, max_retries=2):
    """Retry transient failures with exponential backoff."""
    for attempt in range(max_retries + 1):
        result = await research_company(client, company, brief)
        if result["status"] == "success":
            return result
        if attempt < max_retries:
            wait = 2 ** attempt * 5  # 5s, 10s
            print(f"[{company['company']}] Retry {attempt+1} in {wait}s...")
            await asyncio.sleep(wait)
    return result  # Return last attempt even if failed

Screenshot on failure

When extraction fails, save a screenshot of what the browser saw. This is invaluable for debugging — was it a CAPTCHA? A paywall? A 404? A Cloudflare challenge?

python
except Exception as e:
    # Take a failure screenshot before releasing the browser
    try:
        screenshot = await page.screenshot()
        with open(f"failures/{company_name}_error.png", "wb") as f:
            f.write(screenshot)
    except:
        pass
    return {"company": company_name, "status": "error", "error": str(e)}

Real-world example: competitive analysis for a SaaS startup

Here's what a production-quality research brief and extraction schema look like. This example analyzes 30 competitors in the project management SaaS space.

The research brief

markdown — research_brief.md
# Competitive Analysis: Project Management SaaS

## Objective
Map the competitive landscape of project management tools targeting
teams of 10-200 people. Focus on pricing leverage points, feature
gaps, and positioning opportunities.

## Per-company extraction schema

For each company, extract the following into structured JSON:

### Pricing
- Free tier: exists? user limit? feature limits?
- Lowest paid tier: name, price/user/month, key features
- Enterprise tier: listed price or "contact sales"?
- Annual vs monthly pricing delta
- Any usage-based pricing components

### Product
- Primary value proposition (first heading on homepage)
- Key features listed on product/features page
- Integrations page: which tools do they integrate with?
- AI features: do they mention AI? what specifically?
- Mobile apps: iOS? Android? Both?

### Positioning
- Target company size (from messaging)
- Industry focus (vertical or horizontal?)
- Competitor comparisons on their site (vs pages)
- Customer logos displayed
- Testimonial quotes (first 3)

### Recent changes
- Blog: any product announcements in last 90 days?
- Pricing page: any "new" or "coming soon" badges?
- Changelog: last entry date?

### Metadata
- Exact URL for each data point
- Page title
- Timestamp of visit

The extraction output

Each browser container produces a structured JSON object following the schema. Here's a realistic example of what one extraction looks like:

json — results/linear.json
{
  "company": "Linear",
  "research_date": "2026-03-27T06:15:00Z",
  "pages_visited": 4,
  "pricing": {
    "free_tier": {
      "exists": true,
      "user_limit": "Up to 250 issues",
      "feature_limits": "No roadmaps, limited integrations"
    },
    "lowest_paid": {
      "name": "Standard",
      "price_per_user_month": 8,
      "billing": "monthly",
      "key_features": ["Unlimited issues", "Roadmaps", "Custom fields"]
    },
    "enterprise": {
      "price": "Contact sales",
      "extras": ["SAML SSO", "SCIM", "Audit log"]
    },
    "annual_discount_pct": 17,
    "source_url": "https://linear.app/pricing"
  },
  "product": {
    "value_proposition": "Linear is a purpose-built tool for planning and building products",
    "key_features": [
      "Issue tracking", "Project management", "Roadmaps",
      "Cycles", "Triage", "Git integration"
    ],
    "ai_features": [
      "AI-powered issue creation from Slack",
      "Auto-categorization of bug reports",
      "Natural language project search"
    ],
    "integrations": ["GitHub", "GitLab", "Slack", "Figma", "Sentry", "Zendesk"],
    "mobile": {"ios": true, "android": true},
    "source_url": "https://linear.app/features"
  },
  "positioning": {
    "target_size": "Startups and scaling teams",
    "vertical": "Horizontal (software companies)",
    "competitor_comparisons": ["vs Jira", "vs Asana", "vs Shortcut"],
    "customer_logos": ["Vercel", "Ramp", "Loom", "Retool", "PostHog"],
    "source_url": "https://linear.app"
  },
  "recent_changes": {
    "latest_blog_post": {
      "title": "Introducing Linear Asks",
      "date": "2026-03-15",
      "source_url": "https://linear.app/blog/introducing-linear-asks"
    },
    "source_url": "https://linear.app/changelog"
  }
}

The compiled report

The orchestrator's Python script takes 30 of these JSON objects and compiles them into a Markdown report with comparative tables, highlight callouts, and actionable recommendations. The report reads like something a human analyst produced — because the synthesis step uses Claude to write the narrative sections.

python — compile step (inside orchestrator.py)
import anthropic

def compile_with_claude(results_json: str, brief: str) -> str:
    """Use Claude to synthesize raw data into a narrative report."""
    client = anthropic.Anthropic()

    response = client.messages.create(
        model="claude-sonnet-4-6-20250514",
        max_tokens=8192,
        messages=[{
            "role": "user",
            "content": f"""You are a senior market analyst. Given the research brief
and the raw extraction data from {len(results)} companies, produce a
comprehensive competitive analysis report in Markdown.

Include:
1. Executive summary (3 paragraphs)
2. Pricing comparison table (all companies, key tiers)
3. Feature matrix (key features vs companies)
4. AI capabilities comparison
5. Positioning map analysis
6. Actionable recommendations (5 bullet points)
7. Appendix: per-company detail sheets

Research brief:
{brief}

Raw data:
{results_json}""",
        }],
    )

    return response.content[0].text

Running it on a schedule

A research agent that runs once is a demo. A research agent that runs every Monday at 6am and delivers a fresh competitive briefing to your Slack channel is an employee.

Cron-triggered research

Wrap the orchestrator in a shell script that creates the sandbox, runs the research, posts results, and cleans up:

bash — weekly-research.sh
#!/bin/bash
# Weekly competitive research — runs every Monday at 6am UTC
set -euo pipefail

SANDBOX_URL="https://sandbox.dev.ab0t.com"
SANDBOX_API_KEY="$RESEARCH_AGENT_API_KEY"

# 1. Create sandbox
SANDBOX=$(curl -s -X POST "$SANDBOX_URL/api/sandboxes" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"weekly-research","instance_type":"ab0t.medium","auto_stop_minutes":60}')
SID=$(echo "$SANDBOX" | jq -r '.sandbox_id')

# 2. Wait for boot
while [ "$(curl -s "$SANDBOX_URL/api/sandboxes/$SID" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" | jq -r '.status')" != "running" ]; do
  sleep 5
done

# 3. Upload scripts and run
curl -s -X POST "$SANDBOX_URL/api/sandboxes/$SID/execute" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"command\": \"cd /workspace && git clone https://github.com/your-org/research-agent.git . && pip install -r requirements.txt && python orchestrator.py\"}"

# 4. Download report and post to Slack
curl -s "$SANDBOX_URL/api/sandboxes/$SID/download" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -o /tmp/research.tar.gz
tar xzf /tmp/research.tar.gz -C /tmp/research
REPORT=$(cat /tmp/research/workspace/report.md)

curl -X POST "$SLACK_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d "{\"text\": \"Weekly competitive research is ready.\n\n$(echo $REPORT | head -20)\"}"

# 5. Delete sandbox
curl -s -X DELETE "$SANDBOX_URL/api/sandboxes/$SID" \
  -H "Authorization: Bearer $SANDBOX_API_KEY"

echo "Done. Sandbox $SID deleted."

Event-triggered research

Instead of a fixed schedule, trigger research in response to events. A Slack message says "run a competitive check on [company]" — a webhook fires, the agent creates a sandbox, researches that company, and posts the results. A GitHub PR adds a new competitor to the tracking list — the CI pipeline triggers a focused research run on just that company.

The infrastructure is the same: create sandbox, upload brief, run orchestrator, download results, clean up. What changes is the trigger.

What it costs

Compute costs for the research run are modest. The real cost is model inference for the extraction and synthesis. Here's the full breakdown:

ItemQuantityUnit costTotal
Terminal sandbox (ab0t.medium) 1 instance × 25 min $0.0416/hour $0.02
Browser containers (Chrome) 50 containers × ~8 min avg ~$0.03/hour each $0.20
Claude Sonnet (extraction) ~100 API calls (2 per company) ~$0.01 per call $1.00
Claude Sonnet (report synthesis) 1 long call with all data ~$0.10 $0.10
Vision extraction (fallback) ~5 screenshots (10% of sites) ~$0.02 per image $0.10
Total per run ~$1.42
Rounded up with overhead ~$2.50

Compare this to the human cost. A research analyst at $75/hour (mid-market, fully loaded) takes 12–16 hours: $900–$1,200 per report. The AI research agent produces a comparable report for $2.50. That's a 400× cost reduction.

Run it weekly for a year: 52 reports × $2.50 = $130/year vs 52 reports × $1,000 avg = $52,000/year in analyst time.

Model costs dominate, not compute

The browser containers and sandbox together cost $0.22. The Claude API calls cost $1.20. As model prices continue to drop (they've fallen 10× in the last 18 months), the total cost will decrease even further. The compute infrastructure is already negligible.

Alternative: let Claude Code orchestrate everything

The Python orchestrator above is explicit and production-grade. But for ad-hoc research, you can skip the custom code entirely and let Claude Code do the orchestration. SSH into a sandbox, run claude, and describe the task in plain English.

prompt to Claude Code (inside sandbox)
> I need a competitive analysis of these 10 companies: [list].
>
> For each one, use the Sandbox Platform API at $SANDBOX_URL to
> create a Chrome browser container, navigate to their website
> and pricing page, extract pricing tiers and key features, then
> release the browser.
>
> After all extractions are done, compile a Markdown report comparing
> all 10 companies in a pricing table and feature matrix. Save it
> to /workspace/competitive-analysis.md.
>
> Use my API key in $SANDBOX_API_KEY. Run all browsers in parallel
> using asyncio. Handle failures gracefully — if a site is down,
> note it and continue with the others.

Claude Code writes the orchestrator script, installs dependencies, creates browser containers through the API, extracts data via CDP, compiles the report, and saves it. You read the report in the morning. The agent did all the work — you just described what you wanted in English.

This approach is ideal for one-off research where writing a custom orchestrator isn't worth it. For recurring research, use the Python script so you can version it, schedule it, and iterate on the extraction schema.

Beyond research: the pattern applies everywhere

The fan-out/fan-in pattern with browser containers isn't unique to competitive research. The same architecture works for any task where an agent needs to visit many websites and produce a consolidated deliverable:

In every case, the shape is identical: a list of URLs, an extraction schema, a fleet of browser containers, a terminal sandbox for synthesis, and a deliverable at the end. Change the brief and the schema, reuse everything else.

Production tips

Store extraction schemas in version control

The research brief and extraction schema are your agent's "job description." Version them in git. When you change what the agent looks for, you can diff the schema and see exactly what changed. This also means you can roll back if a schema change produces worse results.

Save raw extractions alongside the report

Always keep the raw JSON from each browser alongside the compiled report. When stakeholders question a finding, you can trace it back to the exact URL, timestamp, and extracted text. The raw data is also useful for re-analysis without re-crawling.

Use release, not delete

When a browser container finishes its work, call POST /api/containers/{id}/release instead of DELETE. Release returns the container to the warm pool for reuse. Delete terminates the cloud container permanently. Release is faster, cheaper, and makes the next browser claim instant.

Set idle timeouts aggressively on browsers

Browser containers should have short idle timeouts (10–15 minutes). If the orchestrator crashes and never releases a container, the idle timeout ensures it gets cleaned up automatically. You won't be billed for a forgotten Chrome instance running overnight.

Use ab0t.micro for the orchestrator if you're not doing heavy synthesis

The terminal sandbox only needs compute for running Python and calling the Claude API. A ab0t.micro (2 vCPU, 1 GB RAM) at $0.01/hour is sufficient for orchestration. Only upgrade to ab0t.medium if you're processing large datasets or generating charts with matplotlib.

Tag everything with a run ID

Pass a unique run ID through the metadata fields of both sandboxes and browser containers. This lets you trace all resources back to a single research run for cost allocation and debugging.

python
import uuid

run_id = str(uuid.uuid4())[:8]

# Every container gets the run_id in metadata
browser = await client.post(f"{SANDBOX_URL}/api/browsers", json={
    "browser_type": "chrome",
    "enable_debug_port": True,
    "metadata": {
        "run_id": run_id,
        "task": "competitive-research",
        "company": company_name,
    },
})

Monitor with heartbeats

For long-running browser extractions, call POST /api/containers/{id}/activity periodically. This resets the idle timeout and tells the platform the container is still in use. Without heartbeats, a browser sitting idle while the agent thinks about a complex page might get reclaimed.

Troubleshooting

Browser container stuck in "provisioning"

The cloud container takes 30–90 seconds to start. If a container is still in provisioning or waiting_callback after 2 minutes, it likely failed. Check the state with GET /api/containers/{id}/status — if it shows failed, the container failed to start. This usually means you've hit your concurrency limit. Wait and retry, or request a quota increase.

CDP connection refused

The cdp_url is only available after the container reaches assigned state. If you try to connect before that, you'll get a connection refused. Always poll status until state == "assigned" before connecting Playwright.

Extraction returns empty content

The page probably hasn't finished rendering. Increase the wait time after navigation (wait_for_timeout) or use wait_until="networkidle" in Playwright's goto(). Some SPAs need 3–5 seconds to fully render.

Cloudflare or bot detection blocking the browser

Browser containers are real Chrome instances, not headless scrapers. They pass most bot detection. If you're still blocked, ensure you're not running headless shell — use browser_type: "chrome" with a full browser. You can also set a custom user agent and enable cookies for more realistic browsing behavior.

Orchestrator runs out of memory

If you're processing 500+ extraction results in memory, the ab0t.micro might run out. Upgrade to ab0t.medium, or process results in streaming fashion (write each result to disk as it arrives, compile the report by reading from disk).

Sandbox auto-stopped mid-run

The orchestrator sandbox was idle (no commands executing) for longer than auto_stop_minutes. This can happen if the orchestrator is waiting on slow browser containers. Either set auto_stop_minutes: 0 for long research runs, or ensure the orchestrator script keeps the sandbox active by printing periodic status updates.


What's next

Put your research on autopilot

50 browser containers. One terminal sandbox. A finished report by morning. Start with the free tier.

Get Started Free