Guide #6 Browser Agents 35 min read March 2026

Playwright Meets Cloud Browser

Every browser agent starts the same way: a script that controls a browser. In 2024, that browser ran on your laptop. In 2026, it runs in an isolated cloud container — API-provisioned, billed by the second, disposable after every task. The browser is no longer a local process. It's infrastructure.

This guide is the mechanical foundation. No AI frameworks, no abstractions. Raw Playwright talking to a remote Chrome container via CDP. Once you understand this, you can build anything: research agents, scraping fleets, form-filling automations, visual testing pipelines, competitive intelligence systems. Every other browser guide in this series builds on what you learn here.

The shift: browsers as cloud infrastructure

For twenty years, browser automation meant running Selenium on a CI server or Puppeteer on your laptop. The browser was a process on a machine you managed. You dealt with Chrome version mismatches, zombie processes, memory leaks, display server configuration, and the fundamental limit that one machine could only run so many browsers before it fell over.

In 2025, three things converged to change this:

  1. LLMs got good enough to reason about web pages. Claude's computer use, GPT-4o's vision, Gemini's multimodal capabilities — suddenly an AI agent could look at a screenshot and decide what to click next. The browser went from a testing tool to an agent's window to the entire internet.
  2. Container infrastructure matured. cloud containers, Fly.io Machines, and Firecracker microVMs made it possible to spin up a browser in seconds and tear it down when done. No servers to manage. No Chrome versions to update. No zombie processes to kill.
  3. The economics flipped. A cloud browser costs $0.007 for 10 minutes. That's less than the electricity your laptop uses running Chrome. When browsers cost nothing, you stop thinking about "how many can I run?" and start thinking about "how many do I need?"

This guide teaches you the mechanics of this new world. By the end, you'll be able to launch browsers on demand, control them from any programming language, and tear them down when you're done — all through API calls.

Who this guide is for

You know Playwright or Puppeteer. You've automated browsers locally. You want to move that automation to the cloud so you can run more browsers, isolate sessions, and let AI agents drive them. This guide gives you the plumbing. The AI parts come in later guides.

Understanding CDP: the protocol that makes this work

The Chrome DevTools Protocol (CDP) is the WebSocket API that Chrome exposes for programmatic control. When you open Chrome's DevTools (F12), you're using CDP. When Playwright calls page.goto(), it's sending a CDP command over a WebSocket.

The key insight: CDP works over the network. The WebSocket doesn't have to connect to a local browser. It can connect to any Chrome instance that exposes a CDP port — including one running in a container thousands of miles away. The latency is a few milliseconds per command, which is imperceptible for automation workflows.

What CDP can do

CDP isn't just "navigate to a URL." It's a comprehensive browser control protocol with domains for:

CDP DomainWhat It ControlsAgent Use Case
PageNavigation, lifecycle events, screenshotsNavigate pages, take screenshots for vision models
DOMRead and modify the document treeExtract text, find elements, read page structure
InputMouse, keyboard, touch eventsClick buttons, fill forms, type text
NetworkRequest/response interceptionMonitor API calls, block ads, inject headers
RuntimeJavaScript execution in page contextRun extraction scripts, modify page behavior
EmulationDevice metrics, geolocation, user agentMobile emulation, geo-specific content
StorageCookies, localStorage, sessionStorageManage authentication state, persist sessions
TargetBrowser contexts, tabs, pagesMulti-tab workflows, iframe handling

Playwright and Puppeteer abstract these domains into friendly APIs (page.click(), page.fill(), etc.), but understanding that CDP is underneath helps you debug issues and build advanced patterns.

Your Code → Playwright API → CDP WebSocket Chrome Container Playwright translates your high-level calls into CDP commands sent over WebSocket to the remote browser.

Launching a cloud browser

A single API call creates an isolated Chrome container. The container runs as isolated cloud compute — no servers for you to manage. Each browser gets its own IP address, its own cookies, its own session state. Nothing is shared between browsers.

Using curl

bash
# Launch a Chrome browser in the cloud
curl -s -X POST "https://sandbox.dev.ab0t.com/api/browsers" \
  -H "Authorization: Bearer $SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "browser_type": "chrome",
    "homepage_url": "https://example.com",
    "idle_timeout_minutes": 30,
    "enable_debug_port": true
  }' | jq .

The response

json
{
  "container_id": "ctr-7f3a2b1c",
  "browser_type": "chrome",
  "status": "running",
  "state": "provisioning",
  "cdp_url": "ws://54.186.23.117:9222/devtools/browser/a1b2c3d4-e5f6-...",
  "access_url": "https://ctr-7f3a2b1c.browser.ab0t.com?token=tok_9x8y7z...",
  "debug_port": 9222,
  "session_token": "tok_9x8y7z...",
  "created_at": "2026-03-27T14:30:00Z",
  "from_pool": false,
  "mode": "interactive+agent"
}

Two URLs matter:

The from_pool field

If warm pools are enabled, from_pool: true means the browser was claimed from a pre-provisioned pool — it's ready in under 1 second instead of the 15-30 second cold start. See the warm pools guide for setup.

Request parameters explained

ParameterTypeDefaultWhat It Does
browser_typestringfirefoxWhich browser engine. Options: chrome, firefox, chromium-headless, selenium-chrome, headless-shell, lightpanda
homepage_urlstringblankURL to navigate to on launch. Sets the startup page so the browser is already where you need it when you connect.
idle_timeout_minutesinteger30Auto-terminate after N minutes with no CDP connections or page activity. Safety net against forgotten browsers.
enable_debug_portbooleantrueExpose CDP on port 9222. Set false if you only need the access_url for visual monitoring.
cpustring1024vCPU allocation (0.25, 0.5, 1, 2, or 4 vCPU). More CPU = faster page loads and JavaScript execution.
memorystring2048Memory in MB. Chrome is hungry — 2048 is minimum for complex sites. Use 4096 for pages with heavy JavaScript.
environmentobject{}Environment variables passed to the container. Useful for proxy configuration or custom Chrome flags.
metadataobject{}Arbitrary key-value pairs for your own tracking. Shows up in listings and billing reports.

Connecting Playwright

Playwright has native support for connecting to remote browsers via CDP. The key method is chromium.connect_over_cdp() instead of the usual chromium.launch().

Python (async)

python
# pip install playwright httpx
# playwright install chromium  (only needed for type stubs, not the actual browser)

import asyncio
import httpx
from playwright.async_api import async_playwright

SANDBOX_URL = "https://sandbox.dev.ab0t.com"
API_KEY = "ab0t_sk_live_YOUR_KEY_HERE"


async def launch_cloud_browser(
    browser_type: str = "chrome",
    homepage: str = "",
) -> dict:
    """Launch a cloud browser and return the connection details."""
    async with httpx.AsyncClient(timeout=60) as http:
        resp = await http.post(
            f"{SANDBOX_URL}/api/browsers",
            headers={"Authorization": f"Bearer {API_KEY}"},
            json={
                "browser_type": browser_type,
                "homepage_url": homepage,
                "idle_timeout_minutes": 30,
            },
        )
        resp.raise_for_status()
        return resp.json()


async def wait_for_ready(container_id: str, max_wait: int = 60) -> None:
    """Poll until the container is ready (CDP available)."""
    async with httpx.AsyncClient(timeout=10) as http:
        for _ in range(max_wait // 2):
            resp = await http.get(
                f"{SANDBOX_URL}/api/containers/{container_id}/status",
                headers={"Authorization": f"Bearer {API_KEY}"},
            )
            data = resp.json()
            state = data.get("state", "")
            if state in ("ready", "assigned"):
                return
            await asyncio.sleep(2)
    raise TimeoutError(f"Container {container_id} didn't become ready in {max_wait}s")


async def terminate_browser(container_id: str) -> None:
    """Terminate a cloud browser. Billing stops immediately."""
    async with httpx.AsyncClient(timeout=10) as http:
        await http.delete(
            f"{SANDBOX_URL}/api/containers/{container_id}",
            headers={"Authorization": f"Bearer {API_KEY}"},
        )


async def main():
    # 1. Launch
    browser_info = await launch_cloud_browser("chrome", "https://news.ycombinator.com")
    container_id = browser_info["container_id"]
    cdp_url = browser_info["cdp_url"]
    print(f"Launched: {container_id}")
    print(f"Watch live: {browser_info['access_url']}")

    # 2. Wait for ready
    await wait_for_ready(container_id)
    print("Browser ready.")

    # 3. Connect Playwright
    async with async_playwright() as pw:
        browser = await pw.chromium.connect_over_cdp(cdp_url)
        context = browser.contexts[0]
        page = context.pages[0] if context.pages else await context.new_page()

        # 4. The page is already on Hacker News (set by homepage_url)
        await page.wait_for_load_state("networkidle")
        title = await page.title()
        print(f"Page title: {title}")

        # 5. Extract the top 30 stories
        stories = await page.evaluate("""() => {
            return Array.from(document.querySelectorAll('.athing')).map(row => {
                const titleEl = row.querySelector('.titleline > a');
                const subtext = row.nextElementSibling;
                const scoreEl = subtext?.querySelector('.score');
                const ageEl = subtext?.querySelector('.age');
                return {
                    rank: row.querySelector('.rank')?.textContent?.trim(),
                    title: titleEl?.textContent?.trim(),
                    url: titleEl?.href,
                    points: scoreEl?.textContent?.trim() || '0 points',
                    age: ageEl?.textContent?.trim() || '',
                };
            });
        }""")

        print(f"\nTop {len(stories)} stories:")
        for s in stories[:10]:
            print(f"  {s['rank']:>3s} {s['points']:>12s}  {s['title']}")

        # 6. Take a screenshot
        await page.screenshot(path="hn-frontpage.png", full_page=True)
        print(f"\nScreenshot saved: hn-frontpage.png")

        # 7. Click into the top story and extract content
        if stories:
            first_url = stories[0]["url"]
            if first_url and first_url.startswith("http"):
                await page.goto(first_url)
                await page.wait_for_load_state("domcontentloaded")
                body_text = await page.evaluate("() => document.body.innerText.slice(0, 500)")
                print(f"\nFirst story preview:\n{body_text}...")

        await browser.close()

    # 8. Terminate
    await terminate_browser(container_id)
    print(f"\nBrowser {container_id} terminated. Billing stopped.")


asyncio.run(main())
What just happened

You launched a Chrome browser in the cloud, connected to it over the internet via CDP, navigated Hacker News, extracted structured data from 30 stories, took a full-page screenshot, clicked into an article, extracted its text, and terminated the browser. Total cost: about $0.003. Total time: about 30 seconds (15 for container start, 15 for automation).

Node.js with Puppeteer

Puppeteer uses the same CDP protocol. The API is nearly identical:

javascript
// npm install puppeteer-core
const puppeteer = require('puppeteer-core');

const CDP_URL = 'ws://54.186.23.117:9222/devtools/browser/a1b2c3d4...';

(async () => {
    const browser = await puppeteer.connect({ browserWSEndpoint: CDP_URL });
    const pages = await browser.pages();
    const page = pages[0] || await browser.newPage();

    await page.goto('https://news.ycombinator.com', { waitUntil: 'networkidle2' });

    // Extract stories
    const stories = await page.evaluate(() => {
        return Array.from(document.querySelectorAll('.athing')).slice(0, 10).map(row => ({
            title: row.querySelector('.titleline > a')?.textContent,
            url: row.querySelector('.titleline > a')?.href,
        }));
    });

    stories.forEach((s, i) => console.log(`${i+1}. ${s.title}`));

    await page.screenshot({ path: 'hn.png', fullPage: true });
    await browser.close();
})();

Python (sync, for simpler scripts)

If you don't need async, Playwright's sync API works too:

python
from playwright.sync_api import sync_playwright

cdp_url = "ws://54.186.23.117:9222/devtools/browser/a1b2c3d4..."

with sync_playwright() as pw:
    browser = pw.chromium.connect_over_cdp(cdp_url)
    page = browser.contexts[0].pages[0]

    page.goto("https://example.com")
    print(page.title())
    page.screenshot(path="example.png")

    browser.close()

Choosing the right browser type

Six browser types are available, each optimized for a different use case. Picking the right one affects cost, performance, and compatibility.

TypeEngineHeadlessCDPResourcesCost/hrBest For
chrome Chromium 124+ No (visible) Full 1 vCPU, 2 GB $0.04 General automation. Most compatible. Use when you need screenshots or access_url for live viewing. The default choice.
headless-shell Chrome headless shell Yes Full 0.5 vCPU, 1 GB $0.02 Pure scraping. No visual output. 50% cheaper than chrome. Fastest startup. Use when you only need DOM access and network interception.
chromium-headless Chromium (headless mode) Yes Full 1 vCPU, 2 GB $0.04 Same as chrome but in headless mode. Screenshots work but no live viewing. For when you need full Chrome features without the display overhead.
firefox Gecko (Firefox) No Limited 1 vCPU, 2 GB $0.04 Cross-browser testing. Some sites behave differently in Firefox. CDP support is limited — use Playwright's Firefox protocol instead.
selenium-chrome Chromium + WebDriver No Full + WebDriver 1 vCPU, 2 GB $0.04 Selenium Grid compatibility. Use when you have existing Selenium tests you want to run in the cloud without rewriting.
lightpanda Lightweight engine Yes Partial 0.25 vCPU, 512 MB $0.01 Ultra-fast scraping of simple pages. Minimal JavaScript support. 75% cheaper than chrome. For static content extraction at massive scale.
Decision tree

Need to see the browser? Use chrome.
Pure data extraction? Use headless-shell.
100+ browsers in parallel? Use lightpanda if the sites are simple, headless-shell if they're JavaScript-heavy.
Existing Selenium tests? Use selenium-chrome.
Cross-browser testing? Add firefox.

Automation patterns

These are the building blocks. Every browser agent combines some subset of these patterns.

Pattern 1: Extract structured data from a page

The most common pattern. Navigate to a page, run a JavaScript extraction function, get back structured JSON.

python
# Extract product listings from an e-commerce page
await page.goto("https://store.example.com/products")
await page.wait_for_selector(".product-card")

products = await page.evaluate("""() => {
    return Array.from(document.querySelectorAll('.product-card')).map(card => ({
        name: card.querySelector('.product-name')?.textContent?.trim(),
        price: card.querySelector('.price')?.textContent?.trim(),
        rating: card.querySelector('.stars')?.getAttribute('data-rating'),
        url: card.querySelector('a.product-link')?.href,
        in_stock: !card.querySelector('.out-of-stock'),
        image_url: card.querySelector('img')?.src,
    }));
}""")

print(f"Found {len(products)} products")
for p in products[:5]:
    print(f"  {p['name']:40s} {p['price']:>10s} {'In stock' if p['in_stock'] else 'OUT'}")

Pattern 2: Fill and submit a form

Log into a portal, navigate a multi-step form, handle dropdowns and file uploads.

python
# Log into a vendor portal
await page.goto("https://vendor-portal.example.com/login")
await page.fill('input[name="email"]', "finance@yourcompany.com")
await page.fill('input[name="password"]', "secure-password-from-vault")
await page.click('button[type="submit"]')

# Wait for redirect to dashboard
await page.wait_for_url("**/dashboard**", timeout=15000)
print("Logged in.")

# Navigate to the invoice section
await page.click('a[href="/invoices"]')
await page.wait_for_selector(".invoice-table")

# Download the latest invoice PDF
async with page.expect_download() as download_info:
    await page.click('.invoice-row:first-child .download-btn')
download = await download_info.value
await download.save_as(f"invoices/{download.suggested_filename}")
print(f"Downloaded: {download.suggested_filename}")

Pattern 3: Screenshots for vision models

Take a screenshot, encode it as base64, send it to Claude or GPT-4o for visual reasoning. This is how computer-use agents work.

python
import base64
import anthropic  # pip install anthropic

# Take a screenshot of the current page
screenshot_bytes = await page.screenshot(full_page=False)
b64_image = base64.b64encode(screenshot_bytes).decode()

# Send to Claude's vision model
client = anthropic.Anthropic()
response = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    messages=[{
        "role": "user",
        "content": [
            {
                "type": "image",
                "source": {"type": "base64", "media_type": "image/png", "data": b64_image},
            },
            {
                "type": "text",
                "text": "What products are shown on this page? List them with prices.",
            },
        ],
    }],
)
print(response.content[0].text)
This is the bridge to AI browser agents

This screenshot-and-reason pattern is the core loop of every vision-based browser agent: take screenshot → send to LLM → get action (click coordinates, text to type) → execute action → repeat. The vision model browser agent guide builds a full agent on top of this pattern.

Pattern 4: Intercept network requests

Monitor, modify, or block network requests. Useful for capturing API responses, blocking analytics, or injecting authentication headers.

python
# Capture all API responses the page makes
api_responses = []

async def capture_api(response):
    if "/api/" in response.url and response.status == 200:
        try:
            body = await response.json()
            api_responses.append({
                "url": response.url,
                "method": response.request.method,
                "data": body,
            })
        except:
            pass

page.on("response", capture_api)

# Navigate — the page's JavaScript will make API calls
await page.goto("https://app.example.com/dashboard")
await page.wait_for_timeout(5000)  # Let it load fully

print(f"Captured {len(api_responses)} API responses")
for r in api_responses:
    print(f"  {r['method']:>4s} {r['url']}")

# The api_responses list now contains the actual JSON data
# that the page's frontend fetched — often more structured than
# what's visible in the DOM. This is the scraping shortcut for
# single-page apps.

Pattern 5: Multi-page navigation

Follow links, paginate through results, navigate complex site structures.

python
# Paginate through search results and collect all items
all_results = []
page_num = 1

await page.goto("https://search.example.com?q=ai+agents")

while True:
    await page.wait_for_selector(".result-item")

    # Extract results from this page
    results = await page.evaluate("""() =>
        Array.from(document.querySelectorAll('.result-item')).map(el => ({
            title: el.querySelector('.title')?.textContent?.trim(),
            url: el.querySelector('a')?.href,
            snippet: el.querySelector('.snippet')?.textContent?.trim(),
        }))
    """)
    all_results.extend(results)
    print(f"Page {page_num}: {len(results)} results (total: {len(all_results)})")

    # Check for next page
    next_btn = await page.query_selector('a.next-page:not(.disabled)')
    if not next_btn or page_num >= 10:
        break

    await next_btn.click()
    await page.wait_for_load_state("networkidle")
    page_num += 1

print(f"\nCollected {len(all_results)} total results across {page_num} pages")

Pattern 6: Persistent authentication

Log in once, save cookies, reuse them in future sessions. Avoids repeating login flows for recurring tasks.

python
import json

# After logging in successfully, save cookies
cookies = await context.cookies()
with open("vendor-cookies.json", "w") as f:
    json.dump(cookies, f)
print(f"Saved {len(cookies)} cookies")

# In a later session, restore cookies before navigating
with open("vendor-cookies.json") as f:
    saved_cookies = json.load(f)
await context.add_cookies(saved_cookies)

# Now navigate directly to the protected page — no login needed
await page.goto("https://vendor-portal.example.com/invoices")
# If cookies are valid, you're already logged in

Container lifecycle and state machine

Browser containers go through a defined set of states. Understanding these helps you write reliable automation that handles startup delays, warm pool claims, and graceful cleanup.

StateWhat's HappeningDurationBilling
provisioningPlatform is provisioning your container10-30 seconds (cold) or <1 second (from pool)Starts at provisioning
waiting_callbackContainer is starting, browser is initializing5-15 secondsActive
readyBrowser is running, CDP port is listening, ready for connectionsUntil idle timeout or explicit terminationActive
assignedClaimed from warm pool and assigned to a userSame as readyActive
releasingBeing returned to the pool (user data flushed)~5 secondsActive
terminatingContainer shutting down~5 secondsStops at termination request
terminatedGone. Resources freed.FinalNone
Always handle the provisioning delay

Don't try to connect Playwright the instant you get the response back. The cdp_url is in the response immediately, but the container isn't actually listening yet. Poll GET /api/containers/{id}/status until state is ready or assigned, or simply sleep 15-20 seconds. Connecting too early produces a "WebSocket connection refused" error.

What it costs

Browser containers bill per second of active time, from provisioning to termination.

BrowserResourcesPer HourPer 10 MinPer 1 Min
chrome1 vCPU, 2 GB$0.04$0.007$0.0007
headless-shell0.5 vCPU, 1 GB$0.02$0.003$0.0003
lightpanda0.25 vCPU, 512 MB$0.01$0.002$0.0002
chrome (4 vCPU)4 vCPU, 8 GB$0.16$0.027$0.0027

To put this in perspective:

The real cost is the model, not the browser

A 15-minute browser session costs $0.01 in compute. The LLM calls to decide what to click and what to extract cost $0.05–$0.50 depending on the model and token count. When budgeting, focus on model costs, not browser costs. The compute is effectively free.

Production tips

Always set idle_timeout_minutes

30 minutes is a good default. For quick scraping tasks, set 10. For long research sessions, set 60. Idle timeout is your safety net against forgotten browsers that run up your bill.

Use headless-shell for pure scraping

If you don't need screenshots, access_url, or visual debugging, headless-shell is 50% cheaper and starts faster. Switch to chrome only when debugging or when you need the visual stream.

Terminate explicitly, don't rely on idle timeout

Call DELETE /api/containers/{id} when you're done. Billing stops immediately. Idle timeout is a safety net, not a strategy.

Use page.wait_for_selector() not page.wait_for_timeout()

Waiting for a specific element is deterministic. Waiting for a fixed number of seconds is fragile and slow. The selector approach handles fast and slow networks equally well.

Handle navigation errors gracefully

Websites go down, pages 404, JavaScript errors crash SPAs. Wrap navigation in try/except and take a screenshot on failure for debugging:

python
try:
    await page.goto(url, timeout=30000)
    await page.wait_for_load_state("networkidle")
except Exception as e:
    await page.screenshot(path=f"error-{container_id}.png")
    print(f"Navigation failed: {e}. Screenshot saved for debugging.")
    # Continue to next URL instead of crashing the whole run

Respect rate limits and robots.txt

Cloud browsers are real browsers with real IPs. If you hit a site too fast, you'll get rate-limited or blocked just like any other client. Add delays between requests, rotate through pages instead of hammering one endpoint, and check robots.txt for sites you scrape regularly.

Use the access_url for live debugging

When developing a new automation flow, open the access_url in your own browser side-by-side with your terminal. You can watch the remote browser in real time — see exactly what the agent sees, catch when a selector is wrong, spot when a page loads differently than expected. This is the fastest way to debug browser automation.

Troubleshooting

WebSocket connection refused

The container isn't ready yet. Wait for state ready by polling GET /api/containers/{id}/status, or sleep 15-20 seconds after creation. Cold-start containers take 15-30 seconds. Warm pool containers are instant.

Playwright can't find selectors

The page might be a single-page app that loads content dynamically. Use page.wait_for_selector() to wait for the element to appear. If the content is inside an iframe, switch to the iframe first: frame = page.frame(name="content").

Page shows CAPTCHA or bot detection

Cloud browsers have real browser fingerprints (not headless markers), but some sites detect automated access. Mitigations: (1) use chrome instead of headless-shell, (2) set a realistic user agent, (3) add human-like delays between actions, (4) use the Emulation CDP domain to set viewport and device metrics.

Screenshots are blank

The page may not have finished rendering. Use await page.wait_for_load_state("networkidle") before taking screenshots. For SPAs, wait for a specific element: await page.wait_for_selector(".main-content").

Memory errors on heavy pages

Chrome uses a lot of memory. Default containers have 2 GB. For JavaScript-heavy pages (dashboards, data visualizations, large SPAs), request 4096 MB: "memory": "4096". For simple pages, 1 GB suffices.

Browser disconnected unexpectedly

Check idle timeout — if there's no CDP activity for idle_timeout_minutes, the container terminates. Increase the timeout or send periodic keepalive commands (e.g., await page.evaluate("1") every few minutes).

What's next

You now know how to launch cloud browsers, connect Playwright, and automate common web tasks. This is the foundation. Here's where to go from here:

Launch a browser in the cloud

Chrome, Firefox, headless. Isolated, metered, API-driven. Each browser is its own container with its own IP, cookies, and session. Start in seconds, pay by the second.

Get Started Free