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
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.
competitor-a.com Chrome #2
competitor-b.com Chrome #3
competitor-c.com … Chrome #50
competitor-z.com
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.
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.
# 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.
# 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.
# 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." }'
# 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]" }'
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.
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:
# 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.
# 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.
# 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
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):
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
# 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
# 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
# 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:
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
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.
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 size | First 50 containers claimed | Idle pool cost / hour | When to use |
|---|---|---|---|
| 0 (no pool) | ~90s (parallel cold-start) | $0 | Occasional research, cost-sensitive |
| 10 | ~60s (10 instant, 40 cloud) | ~$0.30 | Daily research with modest parallelism |
| 25 | ~45s (25 instant, 25 cloud) | ~$0.75 | Multiple daily runs |
| 50 | <5s (all instant) | ~$1.50 | On-demand research with latency SLA |
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:
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
| Strategy | Browsers created | Speed | Cost | Isolation |
|---|---|---|---|---|
| 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.
# 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
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?
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
# 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:
{
"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.
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:
#!/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:
| Item | Quantity | Unit cost | Total |
|---|---|---|---|
| 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.
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.
> 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:
- Lead qualification — visit 200 prospect websites, extract company size, industry, tech stack, and recent funding. Score each lead. Export to your CRM.
- Regulatory monitoring — visit 50 government and regulatory body websites daily. Check for new rules, comment periods, enforcement actions. Flag anything relevant to your industry.
- Recruiting sourcing — visit company career pages and job boards. Extract open positions, tech stacks, salary ranges, and remote policies. Identify companies hiring for roles you compete for.
- Vendor evaluation — research 20 vendors for a procurement decision. Extract pricing, features, customer reviews, compliance certifications, and integration capabilities.
- Academic literature review — visit arxiv, PubMed, and conference proceedings pages. Extract abstracts, authors, citations, and methodology summaries. Compile a literature review with citation graph.
- Portfolio monitoring — for investors, visit the websites and social accounts of 100 portfolio companies weekly. Track product launches, team changes, press mentions, and any signs of trouble.
- Brand monitoring — visit review sites, forums, and social platforms. Extract mentions of your brand, sentiment, and competitive comparisons. Compile into a weekly brand health report.
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.
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