--- name: browser-automation description: Use when automating browser interactions, taking screenshots, scraping authenticated or public pages, or running multi-step web workflows. The single front door for every browser-automation surface (a shared logged-in browser, an isolated per-run browser, Playwright MCP, Playwright scripts, a browser-agent extension, ephemeral Chromium) plus fetch-only tools. Triggers on "automate browser", "take a screenshot of", "scrape this page", "run this in the browser", "Playwright", "browser automation", or when a task needs a logged-in web app. --- # Browser automation The one place to decide HOW to drive a browser, and the safe way to do each. There are six automation surfaces plus fetch-only tools. Pick by what the task needs. Machine-specific values (your CDP port, your artifacts directory, your Chrome for Testing binary, the command that restarts your browser) live in `config.json` next to this file, never in this skill body. ## Decision guide: pick the surface | I want to… | Use | Why | |---|---|---| | Read or act on a **logged-in** site whose session should be reused across runs | **Shared logged-in browser** (surface 1) | One login, reused everywhere, persistent, already running | | Scrape a **bot-hostile** site, or need clean per-run state, or fault isolation | **Isolated per-run browser** (surface 2) | Keeps that session out of the shared, heavily-used profile; separate blast radius | | "Look at this page, find X, click Y": **explore or debug** a UI | **Playwright MCP** (surface 3) | Snapshots let you see and react without blind selectors | | Run the **same multi-step flow** repeatedly (a scraper, a scheduled job) | **Playwright script** (surface 4) | Known path, one call, once built and stable | | Act inside the user's **everyday browser** and its live logins | **Browser-agent extension** (surface 5) | The only surface that uses the daily profile | | Render or screenshot a **public page, no login** (tests, CI) | **Ephemeral bundled Chromium** (surface 6) | Clean slate, nothing to preserve | | Just get a page's **text or markdown**, no interaction | **Fetch-only tools** | No browser needed, far cheaper | | A fetch or browser gets **403'd even in a real browser** | **Check the TLS / network fingerprint FIRST** | The block is often network-layer, not the browser; browser fixes get wasted on it | | Scrape a site that **blocks even a real automated browser** (Cloudflare / DataDome / Akamai class) | **Anti-bot stack** (outlier, see below) | A normal browser over CDP is fingerprintable; dedicated evasion tooling is what gets through | **Default when unsure and it's interactive: Playwright MCP.** **Default when a shared persistent login is the point: the shared logged-in browser.** **Only reach for the anti-bot stack when a normal surface has actually been blocked.** It's heavier and less maintained; don't start there. ### Attended vs unattended: the focus-safety rule (read this for any scheduled job) Before picking a surface, ask: **could this run while the user is at the computer, or unattended?** If yes, it must **not raise or focus a visible window**. A surface that pops a window mid-work is a real bug, not a cosmetic one. | Job timing | Use | Never use | |---|---|---| | **Unattended, or may run during the day** while the user is working | **A headless surface**: your own headless Chrome that opens tabs as *background* CDP targets, or headless ephemeral Chromium | The **visible shared browser** and the **browser-agent extension**; both surface a window that steals focus | | **The user is present / interactive** | Shared browser, Playwright MCP, the extension: all fine | | The subtle part: the shared logged-in browser is great for session reuse but **lives in a visible window**, and activating a tab in it (for example `PUT /json/new`) pulls focus. So "reuse the logged-in browser" and "don't steal focus" can conflict. For an unattended or daytime job that needs a login, drive the shared browser only via background targets, or prefer a headless surface. Disabling MCP servers does **not** protect you here: the shared browser is driven over CDP from a script, not an MCP tool, so a headless agent run with zero MCP servers can still shell out to it and pop a window. ### Anti-bot / hostile sites (outlier stack, not one of the six managed surfaces) Some sites detect and block even a normally-launched Chrome for Testing driven by Playwright. Escalate in **cost order**: 1. **Is it even the browser?** Check the TLS / HTTP2 fingerprint first. Many blocks are JA3/JA4 or HTTP2 mismatches, not the automation layer. A TLS-impersonating HTTP client (`wreq` / `primp` class) sometimes succeeds where a full browser fails, and it's far cheaper to try. 2. **A normal surface, done right.** A real, warmed-up, logged-in browser (surface 1 or 2) over `connectOverCDP` handles most logged-in and moderately-defended work. It's a validated pattern, not a compromise. 3. **Hard bot-detection only.** There's no single winner; it's a benchmark culture. Test per target using the community `techinz/browsers-benchmark` repo. As of late 2026, sturdier defaults are **Camoufox** or **SeleniumBase UC/CDP mode**, then **Patchright**. Raw `undetected-chromedriver` is declining (frequent 403s on the big detectors); don't start a new scraper on it. Tool names in this space churn every few months. Treat them as current, not permanent, and re-check before building. ## The six surfaces ### 1. Shared logged-in browser: the workhorse An always-on **Chrome for Testing** launched with `--remote-debugging-port=` and a **dedicated** `--user-data-dir`, kept alive by a supervisor (launchd, systemd, or similar). Log into a site once in that window and every job reuses the session. The CDP URL lives in `config.json` as `cdp_url`. First, confirm it's up: ```bash curl -s http://127.0.0.1:/json/version # 200 + JSON means it's up ``` Then attach. **Use your own tab, and never close the shared browser:** ```javascript const { chromium } = require('playwright'); const browser = await chromium.connectOverCDP('http://127.0.0.1:'); const ctx = browser.contexts()[0] || (await browser.newContext()); const page = await ctx.newPage(); // ... work ... await page.close(); // close only your page await browser.close(); // DISCONNECTS the CDP client only; the supervisor keeps Chrome up ``` If a site isn't logged in, tell the user to log in once in the Chrome for Testing window. It's a separate profile, so opening their everyday Chrome won't reach it. **One-shot screenshot pattern:** connect, open a new page, screenshot to an explicit absolute path, close only that page, disconnect. Return a structured result (`{ok, path}` or `{ok:false, error}`) and never terminate the shared browser. ### 2. Isolated per-run browser: isolation A **separate** Chrome for Testing launched fresh for one job and closed after, for bot-hostile sites, clean per-run state, or fault isolation. Pick a **free port** (not the shared browser's) and its **own** `--user-data-dir`. Pattern: ``` launch.sh → pkill any stale instance (scoped to THIS profile only) → wait for the port to free → launch CfT with --remote-debugging-port= --user-data-dir= → wait until /json/version returns 200 ')> close.sh → release the profile lock so the next run starts clean ``` Launch without `--enable-automation` so `navigator.webdriver` stays false. Log in once in the window it opens; the session persists in that profile. ### 3. Playwright MCP: explore and debug `mcp__playwright__*` tools. The MCP server spawns its **own** Chromium on a random high port, separate from the shared browser. `browser_snapshot` returns an accessibility tree with element refs, so you act without guessing selectors. Best for exploration, one-offs, and **discovering selectors** to bake into a script. Token cost per snapshot is negligible against the context window; don't avoid it to "save tokens". ### 4. Playwright scripts: repeatable automation Write a Node.js script, run it with `node`. One call can do twenty actions. Attach over CDP to surface 1, 2, or 3, or launch your own (surface 6). Use for stable, known, repeatable flows. Reusable patterns and the gotchas that bite are in **Script patterns** below. ### 5. Browser-agent extension: the user's everyday browser Tools like `mcp__claude-in-chrome__*` drive the user's **actual everyday browser** through an extension, using their real live logins. Opens work in **new tabs**. Start by reading the current tab context, create a fresh tab, and never trigger native `alert` / `confirm` / `prompt` dialogs (they freeze the extension until dismissed by hand). Use only when the task must happen in the user's real browser, and never unattended. ### 6. Ephemeral bundled Chromium: disposable, no login Playwright's own bundled Chromium, launched fresh and thrown away (`chromium.launch()`). No login, no shared profile. For test/CI-style runs. If a run needs auth, it's the wrong surface: use surface 1 or 2 instead of bolting a login onto something built to forget. ### Fetch-only tools (no browser) When you only need a page's **content**, not interaction, don't launch a browser. Use a markdown fetcher or a hosted reader (Firecrawl / Jina Reader class) for JavaScript-heavy pages. Far cheaper than any browser surface. ## Artifacts rule (screenshots, snapshots, traces, downloads): MANDATORY NEVER write browser artifacts to the current working directory or a repo root. They litter `git status` and can leak private page content into a repo. Always pass an **explicit absolute path**: the session scratchpad if you have one, otherwise the `artifacts_dir` from `config.json`. In scripts, make the output directory a required variable at the top; never a bare relative `path:`. ## Which versions to use (pin, don't drift) A Playwright client that's too new for the running Chrome for Testing **hangs on `connectOverCDP`**: the websocket connects, then negotiation times out. So **pin the Playwright version EXACT** (`"playwright": "1.xx.y"`, never `"^1.xx.y"`; the caret is how a working setup drifts into the broken version). The shared browser is the sensitive case because it has many live pages the client auto-attaches to on connect; dedicated and ephemeral browsers are more forgiving, but pin them anyway. After any Playwright or Chrome for Testing bump, run the status check plus a screenshot as the smoke test. A connect stall means the versions disagree. ## Never use the everyday Chrome bundle for automation Automation runs on **Chrome for Testing**, never the user's daily Chrome application. Launching the daily bundle for automation makes it register as their real browser on the system and take over that identity. The one exception is the browser-agent extension (surface 5), which is *supposed* to be their everyday browser. ## Shared browser is down? If the status check fails, the fix is **not** to launch a browser from here. Bring the supervised service back with the restart command in `config.json` (`restart_cmd`). Note that keep-alive supervisors relaunch the browser when its window is closed, so use the supervisor's own stop command when you truly want it down. ## Setup 1. Run a dedicated Chrome for Testing with `--remote-debugging-port=` and its own `--user-data-dir`, log into your sites once in that window, and keep it running under a supervisor. 2. Create `config.json` next to this file: ```json { "cdp_url": "http://127.0.0.1:", "artifacts_dir": "/absolute/path/for/screenshots", "chrome_for_testing_binary": "/absolute/path/to/Google Chrome for Testing", "restart_cmd": "the command that restarts your supervised browser" } ``` 3. Install Playwright, pinned to an exact version that matches your Chrome for Testing build: ```bash npm install playwright@ ``` 4. Playwright MCP server (surface 3), if wanted: ```bash claude mcp add -s user playwright -- npx @playwright/mcp@latest --output-dir ``` ## Script patterns (surface 4) ### Typing into contenteditable fields Many modern web apps use contenteditable divs instead of regular inputs. Playwright's `type()` can be unreliable with these. Use clipboard paste instead: ```javascript async function typeText(page, text) { await page.evaluate(async (t) => { await navigator.clipboard.writeText(t); }, text); // 'Meta+v' on Mac, 'Control+v' on Linux/Windows const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'; await page.keyboard.press(`${modifier}+v`); } ``` ### Waiting for dynamic content When the page is loading or generating content, poll for a visual indicator rather than using fixed waits: ```javascript async function waitForCompletion(page, opts = {}) { const { indicator, // selector that's visible while loading timeoutMs = 120000, settleMs = 3000 // how long the indicator must be gone before we trust it } = opts; const startTime = Date.now(); await page.waitForTimeout(3000); // initial grace period while (Date.now() - startTime < timeoutMs) { const isActive = await page.locator(indicator).isVisible().catch(() => false); if (!isActive) { await page.waitForTimeout(settleMs); const stillGone = !(await page.locator(indicator).isVisible().catch(() => false)); if (stillGone) return true; } await page.waitForTimeout(2000); } return false; // timed out } ``` ### React controlled inputs React intercepts native input events, so `element.value = 'text'` can look like it worked while React's state never updates. Use Playwright's `.fill()`, which fires the right synthetic events. If you must set a value via `page.evaluate()`, also dispatch `input` and `change` with `{ bubbles: true }`. ### Scrolling and visibility Elements below the fold need `scrollIntoViewIfNeeded()` before interaction. MCP's `browser_click` does this automatically; scripts must do it explicitly. ### Duplicate DOM elements Single-page apps render hidden copies of elements for responsive layouts. Use `.first()` or filter by visibility when locating by placeholder or role, or you'll hit strict-mode violations. ### Scrolling screenshots When content is longer than the viewport, capture it in chunks: ```javascript async function scrollingScreenshots(page, container, baseName, dir) { const fs = require('fs'); const el = page.locator(container); if (!(await el.count())) { await page.screenshot({ path: `${dir}/${baseName}.png` }); return; } // Scroll to bottom to force all content to render await el.evaluate(e => e.scrollTop = e.scrollHeight); await page.waitForTimeout(1000); const scrollHeight = await el.evaluate(e => e.scrollHeight); const clientHeight = await el.evaluate(e => e.clientHeight); // Short content: just screenshot if (scrollHeight <= clientHeight * 1.5) { await page.screenshot({ path: `${dir}/${baseName}.png` }); return; } // Scroll back to top await el.evaluate(e => e.scrollTop = 0); await page.waitForTimeout(500); const overlap = 80; const step = clientHeight - overlap; let position = 0; let idx = 1; const maxScreenshots = 5; while (idx <= maxScreenshots) { await el.evaluate((e, pos) => e.scrollTop = pos, position); await page.waitForTimeout(300); const suffix = idx === 1 ? '' : `-${idx}`; await page.screenshot({ path: `${dir}/${baseName}${suffix}.png` }); position += step; if (position >= scrollHeight - clientHeight) { if (idx < maxScreenshots) { idx++; await el.evaluate(e => e.scrollTop = e.scrollHeight); await page.waitForTimeout(300); await page.screenshot({ path: `${dir}/${baseName}-${idx}.png` }); } break; } idx++; } } ``` ### Copying text from the page If the page has a "Copy" button, use it and read from the clipboard: ```javascript async function copyFromButton(page, buttonSelector) { try { const btn = page.locator(buttonSelector).last(); await btn.waitFor({ state: 'visible', timeout: 5000 }); await btn.click(); await page.waitForTimeout(1000); return await page.evaluate(async () => await navigator.clipboard.readText()); } catch (e) { return null; } } ``` ### Null safety with component libraries Many component libraries (Fluent UI, Material UI, and others) return null from `textContent()` or `getAttribute()`. Always guard: ```javascript const text = (await el.textContent().catch(() => '')) || ''; const aria = (await el.getAttribute('aria-label').catch(() => '')) || ''; ``` ### Navigation in SPAs Single-page apps often keep loading resources indefinitely. Use `domcontentloaded` instead of `networkidle`: ```javascript await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); await page.waitForTimeout(5000); // let the SPA render ``` ### Nested iframes Some apps embed content in iframes. Access them with `frameLocator`: ```javascript const frame = page.frameLocator('iframe[src*="target-domain"]'); await frame.locator('button').click(); ``` Cross-origin iframes may not be accessible. If `frameLocator` can't find elements, the iframe is probably cross-origin and you'll need a workaround (screenshot the parent page, or have the user do that step by hand). ### Finding selectors in unfamiliar apps Prefer Playwright MCP's `browser_snapshot` (surface 3) to discover selectors. Without it, dump what's on the page: ```javascript // List all visible buttons with their labels const buttons = page.locator('button:visible'); const count = await buttons.count(); for (let i = 0; i < count; i++) { const btn = buttons.nth(i); const text = (await btn.textContent().catch(() => '')) || ''; const aria = (await btn.getAttribute('aria-label').catch(() => '')) || ''; const box = await btn.boundingBox(); if (box) console.log(`Button: "${text.trim()}" aria="${aria}" y=${Math.round(box.y)}`); } ``` Also try `[role="option"]`, `[role="menuitem"]`, `[role="tab"]` for dropdown and menu items. ### Script template A starting point for a multi-step automation against the shared logged-in browser: ```javascript const { chromium } = require('playwright'); const fs = require('fs'); const CDP_URL = 'http://127.0.0.1:'; // from config.json cdp_url const OUTPUT_DIR = '/absolute/path/for/screenshots'; // from config.json artifacts_dir; REQUIRED, never relative const TASKS = [ { name: 'task-1', prompt: 'Your prompt here' }, { name: 'task-2', prompt: 'Another prompt' }, ]; (async () => { const browser = await chromium.connectOverCDP(CDP_URL); const ctx = browser.contexts()[0] || (await browser.newContext()); const page = await ctx.newPage(); // your own tab, never someone else's fs.mkdirSync(OUTPUT_DIR, { recursive: true }); for (const { name, prompt } of TASKS) { console.log(`Running: ${name}`); const textbox = page.locator('[role="textbox"]'); await textbox.click(); await typeText(page, prompt); await page.waitForTimeout(500); await page.keyboard.press('Enter'); // Wait for the response (customize the indicator selector) // await waitForCompletion(page, { indicator: 'button:has-text("Stop")' }); await page.screenshot({ path: `${OUTPUT_DIR}/${name}.png` }); await page.waitForTimeout(2000); } await page.close(); // close only your page await browser.close(); // disconnect only; the supervisor keeps the shared browser up console.log('Done.'); })(); ``` ### Gotchas - **Ports**: pick your own debugging port and keep it bound to localhost. CDP is unauthenticated; never expose it on your network. Some tools already claim 9222, so check before choosing it. - **Mac vs Linux keyboard**: `Meta+v` on Mac, `Control+v` on Linux/Windows. Use `process.platform` to detect. - **Timeouts**: `page.goto()` defaults to 30s. For slow apps, set `timeout: 60000`. - **Multiple pages**: `browser.contexts()[0].pages()` lists all open tabs in a shared browser. Open your own page rather than grabbing `pages()[0]`, which may be one the user is reading. - **Clipboard permissions**: CDP connections inherit the browser's permissions. If clipboard access fails, the user may need to grant permission to the site first. - **Don't close a shared browser**: `browser.close()` on a `connectOverCDP` connection only detaches your client. Never call it expecting to quit a shared browser; you'd drop your own connection or disrupt someone else's session. - **One profile, one instance**: you can't launch a new `--remote-debugging-port` Chrome against a profile that's already open; it just opens a tab in the existing instance. Use a different `--user-data-dir`, or attach to the running one. ## Design intent (decomposition) Judgment (this file): choosing the surface, driving and interpreting a page, discovering selectors. Mechanical (scripts you keep next to this file): the browser status check and the screenshot capture with the safe connect/close lifecycle. Reference (the pattern section above): the snippets you copy into a script.