
Cdp
- 597 installs
- 476 repo stars
- Updated April 20, 2026
- browser-use/browser-harness-js
cdp is an agent skill with 490 installs that gives coding agents safe, reliable control of a real browser via the Chrome DevTools Protocol in browser-harness-js.
About
cdp is an agent skill from browser-use/browser-harness-js with 490 installs on skills.sh, ranked #5 in its source repository. It enables coding agents to drive a real Chromium browser through the Chrome DevTools Protocol with guardrails for reliable navigation, DOM interaction, and debugging during automation tasks. Developers reach for cdp when agents need programmatic browser control beyond static HTTP fetches—such as testing SPAs, capturing rendered state, or executing multi-step web flows. The skill sits in the browser-harness-js toolkit purpose-built for agent-safe CDP integrations in JavaScript environments.
- Exposes the full Chrome DevTools Protocol (CDP) to Claude, Cursor and other agents
- Enables browser automation, scraping, testing and UI interaction directly from agent workflows
- Runs as a local MCP server with fine-grained permission controls
- Works with headless and headed Chrome sessions
- Provides structured JSON responses for reliable agent consumption
Cdp by the numbers
- 597 all-time installs (skills.sh)
- Ranked #1,594 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/browser-use/browser-harness-js --skill cdpAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 597 |
|---|---|
| repo stars | ★ 476 |
| Last updated | April 20, 2026 |
| Repository | browser-use/browser-harness-js ↗ |
How do agents control a browser via CDP safely?
Give their coding agents safe, reliable control of a real browser via the Chrome DevTools Protocol.
Who is it for?
Developers building agent automations in browser-harness-js who need safe Chrome DevTools Protocol control of a real Chromium browser.
Skip if: Developers who only need static HTTP requests or Playwright test suites without CDP-level agent harness integration.
When should I use this skill?
The user needs a coding agent to navigate, interact with, or debug a real browser using Chrome DevTools Protocol in browser-harness-js.
What you get
CDP-driven browser sessions with reliable navigation, DOM interactions, and debuggable automation traces.
- CDP browser session
- automated navigation traces
By the numbers
- 490 installs on skills.sh
- Ranked #5 in browser-harness-js source collection
Files
CDP — browser-harness-js skill
Custom codegen'd CDP SDK (every method from browser_protocol.json + js_protocol.json gets a typed wrapper) plus a tiny HTTP server that holds one persistent CDP Session. The browser-harness-js CLI auto-starts the server on first use and forwards JS snippets to it.
The SDK lives in the skill's sdk/ directory. In the rest of this doc, <skill-dir> refers to wherever npx skills add installed the skill (Claude Code: ~/.claude/skills/cdp; Cursor: ~/.cursor/skills/cdp; other agents vary). The CLI should be on PATH as browser-harness-js.
Setup (once, first use)
npx skills add drops the skill into your agent's skills directory but does NOT put the CLI on PATH. Before the first call, verify it's reachable and symlink it into any directory on your PATH if not:
# macOS (Apple Silicon + Homebrew)
command -v browser-harness-js >/dev/null || ln -sf <skill-dir>/sdk/browser-harness-js /opt/homebrew/bin/browser-harness-js
# macOS (Intel) / most Linux — may need sudo
command -v browser-harness-js >/dev/null || ln -sf <skill-dir>/sdk/browser-harness-js /usr/local/bin/browser-harness-js
# Linux without sudo (ensure ~/.local/bin is on PATH)
command -v browser-harness-js >/dev/null || { mkdir -p ~/.local/bin && ln -sf <skill-dir>/sdk/browser-harness-js ~/.local/bin/browser-harness-js; }The CLI auto-installs bun on first run if it's missing (the server is Bun-native). Set BROWSER_HARNESS_SKIP_BUN_INSTALL=1 to opt out.
How to use
Just run browser-harness-js '<JS>'. The first call spawns the server in the background; subsequent calls hit the same process and so reuse the same session, the same WebSocket to Chrome, and any globals you set.
browser-harness-js 'await session.connect()'
browser-harness-js 'await session.Page.navigate({url:"https://example.com"})'
browser-harness-js '(await session.Runtime.evaluate({expression:"document.title",returnByValue:true})).result.value'Output is the raw result content — no {ok,result} envelope.
| Result type | stdout |
|---|---|
| string | bare text, no JSON quotes (e.g. Example Domain) |
| number / boolean | 42, true |
| object / array (non-empty) | compact JSON (e.g. {"frameId":"..."}, [1,2,3]) |
undefined / null / "" / {} / [] | empty (no output) |
Errors go to stderr, exit code 1. The CDP error message and JS stack are printed verbatim, e.g.:
Error: CDP -32602: invalid params
at _call (.../session.ts:117:33)
...Detect failure with if browser-harness-js '...'; then ...; else handle_error; fi or by checking $?.
Multi-line snippets via stdin (heredoc). Important: a multi-statement snippet does NOT auto-return the last expression — write return X explicitly. Single-expression snippets passed as the first argument DO auto-return.
browser-harness-js <<'EOF'
const tabs = await listPageTargets();
globalThis.tid = tabs[0].targetId;
await session.use(globalThis.tid);
return globalThis.tid;
EOFCLI commands
| Command | Behavior |
|---|---|
browser-harness-js '<js>' | Auto-start server if needed, eval the JS, print result. |
browser-harness-js <<EOF…EOF | Same, code from stdin. |
browser-harness-js --status | Print health JSON (uptime, connected, sessionId) or exit 1 if down. |
browser-harness-js --start | Explicit start (no-op if already running). |
browser-harness-js --stop | Graceful shutdown. Drops session state. |
browser-harness-js --restart | Stop + start fresh. |
browser-harness-js --logs | tail -f the server log (/tmp/browser-harness-js.log). |
Env vars: CDP_REPL_PORT (default 9876), CDP_REPL_LOG (default /tmp/browser-harness-js.log).
API surface inside snippets
These globals are pre-loaded — no imports needed:
session— the persistentSession. Has every CDP domain mounted:session.Page,session.DOM,session.Runtime,session.Network, … 56 domains, 652 methods total.listPageTargets()— list real page targets via CDP'sTarget.getTargets(works on Chrome 144+ too), withchrome://anddevtools://URLs filtered out. No args — uses the connected session.detectBrowsers()— scan OS-specific profile dirs for running Chromium-based browsers with remote debugging on. Returns[{name, profileDir, port, wsPath, wsUrl, mtimeMs}], sorted by most recently launched.resolveWsUrl(opts)— resolve a WS URL from{wsUrl}|{port, host?}|{profileDir}. For the no-args auto-detect flow, callsession.connect()directly instead.CDP— the generated namespaces (CDP.Page,CDP.Runtime, …) for type-name reference.
Calling a CDP method
Every method takes a single object argument matching the CDP wire params; it resolves to the typed return value (no result envelope, no id correlation — handled for you).
// no params
await session.DOM.enable()
// required params
await session.Page.navigate({ url: 'https://example.com' })
// all-optional params (object also optional)
await session.Page.captureScreenshot()
await session.Page.captureScreenshot({ format: 'png', quality: 80 })
// returns are stripped to the typed shape
const { root } = await session.DOM.getDocument()
const { nodeId } = await session.DOM.querySelector({ nodeId: root.nodeId, selector: 'h1' })Connecting
Default: just call `session.connect()` with no args. It auto-detects running Chromium-based browsers (Chrome, Chromium, Edge, Brave, Arc, Vivaldi, Opera, Comet, Canary) by scanning OS-specific profile dirs for a DevToolsActivePort file, ordered by most-recently-launched, and picks the first one whose WebSocket accepts. OS-agnostic — works on macOS, Linux, Windows.
await session.connect() // auto-detectUse detectBrowsers() first if you want to see what's available (or let the user pick) before connecting:
const found = await detectBrowsers()
// [{ name: 'Google Chrome', profileDir, port, wsPath, wsUrl, mtimeMs }, ...]Explicit forms — use these only when auto-detect picks the wrong browser, or when you already know where to connect:
| Form | When to use |
|---|---|
{ profileDir } | Target a specific browser when several are running. Reads <profileDir>/DevToolsActivePort directly. |
{ wsUrl } | You already have ws://…/devtools/browser/<uuid> (e.g. piped from elsewhere). |
await session.connect({ profileDir: '/Users/<you>/Library/Application Support/Google/Chrome' })
await session.connect({ wsUrl: 'ws://127.0.0.1:9222/devtools/browser/<uuid>' })Profile paths by OS — use these with { profileDir }:
- macOS:
~/Library/Application Support/<Browser>(e.g.Google/Chrome,Comet,BraveSoftware/Brave-Browser,Arc/User Data) - Linux:
~/.config/<browser>(e.g.google-chrome,chromium,BraveSoftware/Brave-Browser) - Windows:
%LOCALAPPDATA%\<Browser>\User Data(e.g.Google\Chrome,Microsoft\Edge,BraveSoftware\Brave-Browser)
Per-candidate WS-open timeout defaults to 5s — live browsers answer with open/close within ~100ms, so 5s is already generous. The only case where 5s is too short is when Chrome is showing the Allow popup and waiting on the user to click. If you expect that, pass timeoutMs: 30000:
await session.connect({ profileDir: '/Users/<you>/Library/Application Support/Google/Chrome', timeoutMs: 30_000 })If you see `No detected browser accepted a connection` — the browsers have DevToolsActivePort files but none are currently serving WS. Most common cause: remote-debugging is enabled but the user hasn't clicked Allow on the prompt yet. Tell them to click Allow, then retry (or bump timeoutMs).
Picking a target (tab)
After connect(), call session.use(targetId) once; subsequent page-level calls (Page/DOM/Runtime/Network/etc.) auto-route to that target's sessionId. Browser.* and Target.* calls always hit the browser endpoint.
const tabs = await listPageTargets() // no args; uses the connected session
const sid = await session.use(tabs[0].targetId)
await session.Page.enable()
await session.Page.navigate({ url: 'https://example.com' })listPageTargets() uses CDP's Target.getTargets (not /json), so it works on Chrome 144+ too. It already filters out chrome:// and devtools:// URLs. Equivalent raw call:
const { targetInfos } = await session.Target.getTargets({})
const tabs = targetInfos.filter(t => t.type === 'page' && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://'))To switch tabs: session.use(otherTargetId). To detach: session.setActiveSession(undefined).
Events
// Subscribe (returns an unsubscribe fn)
const off = session.onEvent((method, params, sessionId) => { ... })
// Or wait for a single matching event with optional predicate + timeout
await session.Network.enable()
const ev = await session.waitFor(
'Page.frameNavigated',
(p) => p.frame.url.includes('example.com'),
10_000
)Persisting state across calls
Each snippet runs inside its own async wrapper, so its let/const declarations vanish when it returns. To carry data forward, attach to globalThis:
browser-harness-js '(await listPageTargets()).forEach((t,i)=>globalThis["tab"+i]=t.targetId)'
browser-harness-js 'await session.use(globalThis.tab0)'
browser-harness-js 'await session.Page.navigate({url:"https://example.com"})'session itself, the active sessionId, and event subscribers are already preserved by the server — globals are only needed for ad-hoc data.
Connecting to a running Chrome (chrome://inspect flow)
When attaching to the user's already-running browser:
1. Try `await session.connect()` first (no args) — auto-detect handles every Chromium-based browser via DevToolsActivePort. If it returns, you're done. 2. If auto-detect fails with No running browser with remote debugging detected, the user needs to turn it on. Open the inspect page:
# macOS — prefer AppleScript over `open -a` (reuses current profile, avoids the profile picker)
osascript -e 'open location "chrome://inspect/#remote-debugging"'
# Linux
google-chrome 'chrome://inspect/#remote-debugging' # or: chromium, google-chrome-stable
# Windows (PowerShell)
Start-Process chrome 'chrome://inspect/#remote-debugging'Only macOS's AppleScript path avoids the profile picker; Linux/Windows may prompt the user to pick a profile first. 3. Tick "Discover network targets" in chrome://inspect, then click Allow when Chrome prompts. 4. If auto-detect picks the wrong browser (multiple running, you want a specific one): list them with await detectBrowsers(), then await session.connect({ profileDir: <the one you want> }). 5. If `session.connect()` returns `No detected browser accepted a connection`, the user has remote-debugging on but hasn't clicked Allow yet. Tell them to click it and retry, or pass timeoutMs: 30000 to wait for the click.
Working with targets (tabs)
- Filter Chrome internals.
listPageTargets()already dropschrome://anddevtools://URLs. If you callTarget.getTargets()directly, filter manually. - CDP target order ≠ visible tab-strip order. When the user says "the first tab I can see", use a screenshot or page title to identify it —
Target.activateTargetonly switches to a known targetId.
Looking up a method
The full typed surface is in <skill-dir>/sdk/generated.ts (~655 KB, only loaded if you read it). Each method has its CDP description as a JSDoc comment plus typed *Params / *Return interfaces in per-domain namespaces.
grep -n "navigate" <skill-dir>/sdk/generated.ts | headRegenerating the SDK
When the upstream protocol JSONs change, replace sdk/browser_protocol.json and/or sdk/js_protocol.json and re-run:
cd <skill-dir>/sdk && bun gen.ts
browser-harness-js --restart # pick up the new bindingsFiles
All paths are relative to <skill-dir> (the install path — see top of this doc).
/usr/local/bin/browser-harness-js→<skill-dir>/sdk/browser-harness-js(the CLI)sdk/repl.ts— HTTP server (Bun.serveon127.0.0.1:9876)sdk/session.ts—Sessionclass (transport, connect, target routing, events)sdk/generated.ts— codegen output: every CDP method as a typed wrappersdk/gen.ts— codegen scriptsdk/{browser,js}_protocol.json— upstream protocol (vendored)
node_modules/
/tmp/browser-harness-js.log
.DS_Store
Connection & Tab Visibility
Just call session.connect()
No args required. It scans OS-specific profile dirs for every running Chromium-based browser (Chrome, Chromium, Edge, Brave, Arc, Vivaldi, Opera, Comet, Canary), picks the most-recently-launched one whose WebSocket accepts, and attaches. Dead ports and permission-denied (403) candidates fall through in <100ms each, so the loop is fast.
await session.connect()Inspect what's available (e.g. to let the user choose) with detectBrowsers():
const browsers = await detectBrowsers()
// [{ name: 'Google Chrome', profileDir, port, wsPath, wsUrl, mtimeMs }, ...]Explicit forms (override auto-detect)
Use only when auto-detect picks the wrong browser or you already know the destination.
| Form | When |
|---|---|
{ profileDir } | Target a specific running browser. Reads its DevToolsActivePort directly. OS-agnostic. |
{ wsUrl } | You already have ws://…/devtools/browser/<uuid>. |
await session.connect({ profileDir: '/Users/<you>/Library/Application Support/Google/Chrome' })
await session.connect({ wsUrl: 'ws://127.0.0.1:9222/devtools/browser/<uuid>' })Timeouts and the Allow popup
Per-candidate WS-open timeout defaults to 5s. A live browser either opens or closes the connection within ~100ms, so 5s is always enough — unless the user has to click Allow on Chrome's remote-debugging popup. In that case, pass timeoutMs: 30000 to give them time:
await session.connect({ profileDir, timeoutMs: 30_000 })If session.connect() reports No detected browser accepted a connection, it means every browser with DevToolsActivePort answered 403 or closed without opening — most likely the user hasn't clicked Allow yet. Ask them to, then retry.
The omnibox popup problem
When Chrome opens fresh, the only CDP type: "page" targets may be chrome://inspect and chrome://omnibox-popup.top-chrome/ (a 1px invisible viewport). If you attach to the omnibox popup, every subsequent action happens on a tab the user cannot see.
listPageTargets() already filters chrome:// and devtools:// URLs. If you call Target.getTargets directly, filter these manually:
const { targetInfos } = await session.Target.getTargets({})
const realTabs = targetInfos.filter(t =>
t.type === 'page' &&
!t.url.startsWith('chrome://') &&
!t.url.startsWith('devtools://')
)If no real pages exist yet, create one instead of attaching to nothing:
const tabs = await listPageTargets()
let targetId = tabs[0]?.targetId
if (!targetId) {
({ targetId } = await session.Target.createTarget({ url: 'about:blank' }))
}
await session.use(targetId)Startup sequence
1. await session.connect() — auto-detect the running browser. 2. const tabs = await listPageTargets() — see what real pages exist. 3. await session.use(tabs[0].targetId) — route Page/DOM/Runtime/Network calls to that target. 4. await session.Target.activateTarget({ targetId: tabs[0].targetId }) — bring the tab visually to front. 5. Enable the domains you need: await session.Page.enable(), await session.Network.enable({}), etc.
CDP target order ≠ visible tab-strip order
When the user says "the first tab I can see", do NOT trust the order of Target.getTargets. Use:
- A screenshot (
session.Page.captureScreenshot()) to identify visually. - Page title / URL heuristics.
- Or platform UI automation (macOS: AppleScript; Linux:
xdotool/wmctrl).
Target.activateTarget only switches to a targetId you already know — it cannot resolve "leftmost tab".
Bringing Chrome to front
# macOS — prefer AppleScript over `open -a` (reuses current profile, avoids the profile picker)
osascript -e 'tell application "Google Chrome" to activate'
# Linux (X11) — use wmctrl or xdotool
wmctrl -a 'Google Chrome'
xdotool search --name 'Google Chrome' windowactivate
# Windows (PowerShell)
powershell -NoProfile -Command "(New-Object -ComObject WScript.Shell).AppActivate('Google Chrome')"Cookies
Use Network.* for cookies scoped to the attached page/context; use Storage.getCookies / Storage.setCookies for every cookie in the browser.
Read
await session.Network.enable({})
// All cookies visible to the attached page (current origin + its frames)
const { cookies } = await session.Network.getCookies({})
// Cookies for specific URLs
const { cookies: github } = await session.Network.getCookies({
urls: ['https://github.com/'],
})
// Every cookie across the whole browser (requires Storage domain)
const { cookies: all } = await session.Storage.getCookies({})Shape: { name, value, domain, path, expires, size, httpOnly, secure, session, sameSite?, sourceScheme?, priority? }.
Write
// Single cookie on the attached page
await session.Network.setCookie({
name: 'session',
value: 'abc123',
domain: '.example.com',
path: '/',
secure: true,
httpOnly: true,
sameSite: 'Lax',
expires: Date.now() / 1000 + 86400, // seconds since epoch
})
// Bulk import (e.g. to preload an auth session)
await session.Network.setCookies({
cookies: [
{ name: 'a', value: '1', domain: '.example.com', path: '/' },
{ name: 'b', value: '2', domain: '.example.com', path: '/' },
],
})Delete / clear
await session.Network.deleteCookies({ name: 'session', domain: '.example.com' })
await session.Network.clearBrowserCookies() // nukes everything in the default contextGotchas
Network.setCookiesilently fails with no error ifdomaindoesn't match any origin in the current profile — you'll get{ success: true }and the cookie just won't be there. Verify withgetCookiesafter.expiresis seconds (float), not milliseconds. A common mistake.- Session cookies: pass no
expiresand Chrome treats them as session-scoped. Settingexpires: 0also works. sameSitevalues are'Strict'|'Lax'|'None'. For'None', Chrome also requiressecure: true.- Clearing cookies does NOT clear localStorage/IndexedDB. For a full logout, also call
Storage.clearDataForOrigin({ origin, storageTypes: 'all' }).
Cross-Origin Iframes (OOPIFs)
Cross-origin iframes (stripe.com checkout, recaptcha, Salesforce Lightning, Azure blades) run in out-of-process iframes (OOPIFs) with their own CDP target. You cannot reach them via contentDocument from the parent.
First try: coordinate clicks
Compositor-level input passes through OOPIFs transparently. If the thing you want is a button you can see in a screenshot, try this first — it's simpler, undetectable, and doesn't need attaching to anything:
// Click a "Pay" button inside a Stripe iframe by page coordinates
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })Coordinate-based typing also works if you click first, then Input.insertText/Input.dispatchKeyEvent.
When you need DOM inside the OOPIF
Find the iframe target and route Runtime/DOM calls to it:
const { targetInfos } = await session.Target.getTargets({})
const iframe = targetInfos.find(t => t.type === 'iframe' && t.url.includes('stripe.com'))
// Route subsequent calls to the iframe target
await session.use(iframe.targetId)
await session.Runtime.enable()
const { result } = await session.Runtime.evaluate({
expression: 'document.querySelector("[name=cardnumber]").value',
returnByValue: true,
})
// Switch back to the parent page when done
await session.use(parentTargetId)session.use(iframe.targetId) auto-attaches if not already attached, and routes Page/DOM/Runtime/Network to it. Target.* and Browser.* always hit the browser endpoint regardless of use.
Which target is which?
Target.getTargets returns all OOPIFs in the page, flat. If multiple iframes share an origin (e.g. multiple Stripe Elements), you need more than URL to disambiguate:
- Filter by URL path (
cardNumbervscardExpiryvscvcin Stripe). - Enumerate in DOM order from the parent: find all
<iframe>elements, map theirsrcto target URLs. - Inspect title via
Target.getTargetInfo({ targetId }).
Listening to events from an OOPIF
After session.use(iframe.targetId), events for that target arrive via the same session.onEvent / session.waitFor:
await session.use(iframe.targetId)
await session.Network.enable({})
const ev = await session.waitFor(
'Network.responseReceived',
(p) => p.response.url.includes('/confirm_payment'),
10_000
)Traps
- An OOPIF is not always present until interaction. Stripe's card iframe is lazy-mounted after you focus the outer input. Screenshot + coordinate-click the outer input first, then re-query
Target.getTargets. - OOPIF targets disappear when the parent navigates. A cached
iframe.targetIdfrom before a navigation is dead. - CSP / sandbox may block `Runtime.evaluate` side effects even when you've attached. Read-only calls usually work; writes may silently no-op.
- Don't `use(iframe.targetId)` and forget to switch back. Your next
Page.navigategoes to the iframe instead of the main frame. Always pair with asession.use(parentTargetId).
Dialogs
alert, confirm, prompt, beforeunload freeze the JS thread. Two approaches depending on timing.
Reactive: dismiss via CDP (preferred)
Works even when JS is frozen. Handles all four dialog types.
await session.Page.enable()
// Dismiss / accept
await session.Page.handleJavaScriptDialog({ accept: true }) // "OK"
await session.Page.handleJavaScriptDialog({ accept: false }) // "Cancel"
await session.Page.handleJavaScriptDialog({ accept: true, promptText: 'hi' }) // for prompt()
// Wait for a dialog to open (and read its text)
const ev = await session.waitFor('Page.javascriptDialogOpening', undefined, 10_000)
console.log(ev.type, ev.message) // "alert"|"confirm"|"prompt"|"beforeunload"Undetectable by antibot — no JS runs in the page.
Subscribe to every dialog while a flow runs:
await session.Page.enable()
const off = session.onEvent(async (method, params) => {
if (method === 'Page.javascriptDialogOpening') {
await session.Page.handleJavaScriptDialog({ accept: true })
}
})
// ...do actions that may trigger dialogs...
off()Proactive: stub via JS
Prevents dialogs from ever appearing. Good when you expect many alert()/confirm() calls.
await session.Runtime.evaluate({ expression: `
window.__dialogs__ = [];
window.alert = m => window.__dialogs__.push(String(m));
window.confirm = m => { window.__dialogs__.push(String(m)); return true; };
window.prompt = (m, d) => { window.__dialogs__.push(String(m)); return d || ''; };
` })
// ...actions...
const { result } = await session.Runtime.evaluate({
expression: 'window.__dialogs__ || []',
returnByValue: true,
})Tradeoffs:
- Stubs are lost on page navigation — re-inject after every navigate.
confirm()always returnstrue.- Detectable by antibot (
window.alert.toString()reveals non-native code). - Does not handle
beforeunload.
beforeunload specifically
Fires when navigating away from a page with unsaved changes (forms, editors). The page freezes until the user clicks Leave/Stay.
// Option A: dismiss after navigating (CDP, safe, undetectable)
await session.Page.navigate({ url: 'https://new-url.com' })
try {
await session.Page.handleJavaScriptDialog({ accept: true }) // "Leave"
} catch { /* no dialog — normal */ }
// Option B: prevent before navigating (JS, detectable)
await session.Runtime.evaluate({ expression: 'window.onbeforeunload = null' })
await session.Page.navigate({ url: 'https://new-url.com' })Downloads
Two modes: let Chrome write the file to a directory you control, or intercept the download response in CDP and save it yourself.
Route downloads to a directory you own
// Cross-platform temp dir: /tmp on Linux, /var/folders/… on macOS, %TEMP% on Windows
const { tmpdir } = await import('node:os')
const downloadDir = `${tmpdir()}/cdp-downloads`
await Bun.write(`${downloadDir}/.keep`, '') // ensure dir exists
await session.Browser.setDownloadBehavior({
behavior: 'allow',
downloadPath: downloadDir,
eventsEnabled: true, // emit Browser.downloadWillBegin / downloadProgress
})Now any download — whether triggered by a link (<a download>), a window.location to a binary, or a form POST that returns Content-Disposition: attachment — saves to that directory.
Signal that a download actually started
const ev = await session.waitFor(
'Browser.downloadWillBegin',
(p) => p.suggestedFilename.endsWith('.pdf'),
10_000
)
console.log(ev.guid, ev.suggestedFilename, ev.url)Signal that it finished
const done = await session.waitFor(
'Browser.downloadProgress',
(p) => p.state === 'completed',
60_000
)
console.log(done.receivedBytes, done.totalBytes)Browser.downloadProgress.state is one of 'inProgress' | 'completed' | 'canceled'.
Skip the browser entirely for plain HTTP downloads
If the download URL is a plain HTTP GET with no auth/cookie state the browser added, fetch directly from the Bun snippet:
const { tmpdir } = await import('node:os')
const res = await fetch('https://example.com/report.pdf')
await Bun.write(`${tmpdir()}/report.pdf`, await res.arrayBuffer())This is often 10× faster than driving the browser. But it loses cookie-based auth — for logged-in downloads, either: 1. Use the browser path (Browser.setDownloadBehavior), or 2. Copy cookies out first (Network.getCookies) and include them in the fetch.
When the only trigger is a click
If the site exposes only a "Download" button and no obvious URL:
// Pre-arm Browser.setDownloadBehavior, then click
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
const ev = await session.waitFor('Browser.downloadWillBegin', undefined, 10_000)Traps
- `Browser.setDownloadBehavior` is browser-scoped, not page-scoped. Set it once per browser session.
- `downloadPath` must exist. Chrome silently drops the file otherwise — always
mkdir -pfirst. - Files arrive with the suggested filename, not a name you choose. Rename after
state === 'completed'if you need a specific name. - `beforeunload` on the triggering page can block the download. Some sites open a confirm dialog before navigating to the PDF endpoint — handle the dialog first (see
dialogs.md). - If the "download" is actually just inline navigation (PDF viewer opens in-page), there's no
downloadWillBegin— you'll needPage.printToPDFor directfetchinstead.
Drag and Drop
Three kinds hide behind "drag and drop" — each wants a different CDP call.
Kind 1: HTML5 DnD (dragstart / drop events)
React DnD, pragmatic-drag-and-drop, native <div draggable> — all listen to DOM DragEvent. CDP's Input.dispatchMouseEvent with mousePressed/moved/released does not fire these, because the browser synthesizes DragEvents from a native OS drag that CDP doesn't trigger. Use Input.dispatchDragEvent instead:
// Chrome needs to be told we're about to handle drags via CDP
await session.Input.setInterceptDrags({ enabled: true })
// Press at the source
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x: srcX, y: srcY, button: 'left', clickCount: 1 })
// Wait for CDP to deliver the initial drag intent (via Input.dragIntercepted)
const di = await session.waitFor('Input.dragIntercepted', undefined, 2_000)
// Simulate the move + drop via dispatchDragEvent
await session.Input.dispatchDragEvent({ type: 'dragEnter', x: dstX, y: dstY, data: di.data })
await session.Input.dispatchDragEvent({ type: 'dragOver', x: dstX, y: dstY, data: di.data })
await session.Input.dispatchDragEvent({ type: 'drop', x: dstX, y: dstY, data: di.data })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x: dstX, y: dstY, button: 'left', clickCount: 1 })
await session.Input.setInterceptDrags({ enabled: false })This covers most real-world DnD on React/Vue apps (Trello cards, Notion blocks, Linear tickets, Figma layers).
Kind 2: Pointer-based drag (canvas, SVG, custom handlers)
Games, map panning, Figma/Excalidraw canvases, range sliders — these listen for mousedown / mousemove / mouseup (or pointer events) and do their own coordinate math. For these, a plain mouse-event sequence is enough:
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x: x1, y: y1, button: 'left', clickCount: 1 })
// Intermediate moves matter — many sites track velocity / only trigger on movement delta
for (let i = 1; i <= 10; i++) {
const x = x1 + (x2 - x1) * (i / 10)
const y = y1 + (y2 - y1) * (i / 10)
await session.Input.dispatchMouseEvent({ type: 'mouseMoved', x, y, button: 'left' })
}
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x: x2, y: y2, button: 'left', clickCount: 1 })Add intermediate mouseMoved events — sites tracking velocity won't fire on a single jump.
Kind 3: "Drag a file onto this zone" = actually upload
Most drop zones that accept files have a hidden <input type="file"> under them. Use DOM.setFileInputFiles — see uploads.md. Don't fight the DnD path if an input exists.
Traps
- Don't use `Input.dispatchMouseEvent` alone for HTML5 DnD — no
dragstartfires. The site just sees a click that went nowhere. UsesetInterceptDrags+dispatchDragEvent. - Don't use `Input.dispatchDragEvent` without `setInterceptDrags({ enabled: true })` — Chrome routes the drag to the native OS otherwise.
- Snap / animation: after drop, re-screenshot after ~300ms. Some libraries animate the card into place and a too-fast follow-up action lands on the wrong coordinates.
- Pointer Events vs Mouse Events: if
mousedowndoesn't work, trydispatchMouseEventwithpointerType: 'mouse'and also include the same sequence asdispatchPointerEvent(some new SPAs only listen to pointer events).
Dropdowns
The right approach depends on what kind of dropdown the site actually rendered.
Native <select>
Don't click options — set the value directly and fire change. Keyboard/mouse on a native select opens an OS menu CDP can't close.
await session.Runtime.evaluate({ expression: `
(() => {
const s = document.querySelector('select#country')
s.value = 'DE'
s.dispatchEvent(new Event('change', { bubbles: true }))
})()
`})Verify: await session.Runtime.evaluate({ expression: 'document.querySelector("select#country").value', returnByValue: true }).
Custom overlay (div-based menu under a trigger)
1. Click the trigger with Input.dispatchMouseEvent. 2. Re-measure — options appear late, sometimes inside a portal attached to <body>. 3. Click the option by visible text.
// Click the trigger
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x: triggerX, y: triggerY, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x: triggerX, y: triggerY, button: 'left', clickCount: 1 })
// Wait one frame, then find the option by text and coordinate-click it
const { result } = await session.Runtime.evaluate({
returnByValue: true,
expression: `
(() => {
const t = [...document.querySelectorAll('[role="option"], li, .menu-item')]
.find(el => el.textContent.trim() === 'Germany')
if (!t) return null
const r = t.getBoundingClientRect()
return { x: r.x + r.width/2, y: r.y + r.height/2 }
})()
`,
})
if (result.value) {
const { x, y } = result.value
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
}Searchable combobox (React / Downshift / Radix / MUI Autocomplete)
Most comboboxes commit on the keyboard, not the click:
1. Click the input to focus + open. 2. Input.insertText the search string. 3. Wait for options to render. 4. Input.dispatchKeyEvent ArrowDown → Enter to commit.
await session.Input.dispatchKeyEvent({ type: 'keyDown', key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 })
await session.Input.dispatchKeyEvent({ type: 'keyUp', key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 })
await session.Input.dispatchKeyEvent({ type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' })
await session.Input.dispatchKeyEvent({ type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 })Some libraries (notably Radix) require Escape to close without committing. Clicking outside may keep stale input.
Virtualized menus
Long option lists (react-window, TanStack Virtual) only render the visible slice. If your option isn't in the DOM, scroll the menu container with a mouseWheel at its coordinates (see scrolling.md) until it mounts, then coordinate-click.
Traps
- Always re-measure after opening — the trigger's on-screen position may shift when the menu appears (dropdowns that push content).
- Portals: the option DOM may not be a descendant of the trigger. Search
document.querySelectorAll, nottrigger.querySelectorAll. - MUI Autocomplete:
blurcommits the text value, not the selected option. Always use Enter. - CSS
pointer-events: noneon the option means the click passes through — check for an inner<span>or the option container a level up.
Iframes (same-origin)
Same-origin iframes are just part of the parent DOM — you can walk into them via contentDocument. For cross-origin (OOPIFs), see cross-origin-iframes.md.
Reading / writing through contentDocument
await session.Runtime.evaluate({
returnByValue: true,
expression: `
(() => {
const doc = document.querySelector('iframe#inner').contentDocument
return doc.querySelector('h1').textContent
})()
`,
})- Throws
DOMException: Blocked a frame with origin …if the frame is actually cross-origin. That's your signal to switch to OOPIF routing. contentWindow.postMessageworks from the parent if you need to send data in.
Coordinate clicks pass through iframes
The compositor-level input path (Input.dispatchMouseEvent) doesn't care about frame boundaries. If you can see a button in a screenshot, you can click its page coordinates regardless of how many iframes it's nested in:
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })This is usually the lowest-friction approach. Only drop to contentDocument / OOPIF attach when you need to read DOM or dispatch DOM events on elements that are hard to target by coordinate.
Frame-local vs page coordinates
getBoundingClientRect() inside an iframe returns iframe-local coordinates. To coordinate-click, you need page coordinates:
await session.Runtime.evaluate({
returnByValue: true,
expression: `
(() => {
const iframe = document.querySelector('iframe#inner')
const inner = iframe.contentDocument.querySelector('.target')
const iRect = iframe.getBoundingClientRect()
const tRect = inner.getBoundingClientRect()
return { x: iRect.x + tRect.x + tRect.width/2, y: iRect.y + tRect.y + tRect.height/2 }
})()
`,
})Nested iframes
Recurse through contentDocument:
let doc = document
for (const sel of ['iframe#outer', 'iframe#middle', 'iframe#inner']) {
doc = doc.querySelector(sel).contentDocument
if (!doc) throw new Error('cross-origin boundary')
}
return doc.querySelector('h1').textContentTraps
- A frame that was same-origin can become cross-origin after navigation inside it (e.g. OAuth redirect). Re-check with
contentDocumenttruthiness. iframe.contentDocument === nullright after insertion — wait forloadon the iframe before reading.- CSP
frame-ancestors/sandbox="allow-same-origin"can blockcontentDocumentaccess even when origins match.
Network Requests
Use Network.* events when the DOM doesn't tell you whether a request happened, what it sent, or what came back. Use Fetch.* when you need to intercept, modify, or mock.
Watching requests
await session.Network.enable({})
// React to every request
const off = session.onEvent((method, params) => {
if (method === 'Network.requestWillBeSent') {
console.log(params.request.method, params.request.url)
}
if (method === 'Network.responseReceived') {
console.log(params.response.status, params.response.url)
}
})
// ...do the action that should trigger the request...
off()Wait for a specific request
session.waitFor returns just the first matching event's params:
await session.Network.enable({})
// (Trigger the action before awaiting, or trigger concurrently.)
const ev = await session.waitFor(
'Network.responseReceived',
(p) => p.response.url.includes('/api/submit') && p.response.status === 200,
10_000
)
console.log(ev.response.status, ev.requestId)Read a response body
Network.getResponseBody needs the requestId — grab it from the matching event:
const ev = await session.waitFor(
'Network.responseReceived',
(p) => p.response.url.endsWith('/me'),
10_000
)
const { body, base64Encoded } = await session.Network.getResponseBody({ requestId: ev.requestId })
const text = base64Encoded ? Buffer.from(body, 'base64').toString('utf-8') : bodyBodies aren't always available — if the response was a redirect, was cached, or Chrome discarded it, getResponseBody throws. Read it immediately after Network.loadingFinished.
Capture request bodies
Network.requestWillBeSent gives you params.request.postData (for small bodies); use Network.getRequestPostData({ requestId }) for large ones.
Intercept / modify / mock (Fetch domain)
When you need to change what's sent or returned:
await session.Fetch.enable({
patterns: [{ urlPattern: '*/api/flag*', requestStage: 'Response' }],
})
session.onEvent(async (method, params) => {
if (method === 'Fetch.requestPaused') {
// Mock the response
await session.Fetch.fulfillRequest({
requestId: params.requestId,
responseCode: 200,
responseHeaders: [{ name: 'content-type', value: 'application/json' }],
body: Buffer.from(JSON.stringify({ enabled: true })).toString('base64'),
})
}
})Alternatives per request:
session.Fetch.continueRequest({ requestId })— pass through untouched.session.Fetch.continueRequest({ requestId, url, method, postData, headers })— modify in flight.session.Fetch.failRequest({ requestId, errorReason: 'Failed' })— simulate an error.
Fetch.enable disables the HTTP cache for matching URLs. Disable Fetch as soon as you're done.
Cheap SPA "did the action succeed?" signal
Many SPAs mutate state without a visible DOM change. A request-based wait is the cleanest signal:
await session.Network.enable({})
// click Save
await session.Input.dispatchMouseEvent({ type: 'mousePressed', x, y, button: 'left', clickCount: 1 })
await session.Input.dispatchMouseEvent({ type: 'mouseReleased', x, y, button: 'left', clickCount: 1 })
await session.waitFor(
'Network.responseReceived',
(p) => p.response.url.includes('/save') && p.response.status === 200,
10_000
)Traps
- `Network.enable` must be called before the request fires. If you enable after the click, you'll miss the event. Enable once at session start and leave it.
- `Network.enable` is per-target. After
session.use(iframe.targetId), callNetwork.enable({})again inside that target. - Request IDs are unique per target, not global. Don't pass an iframe
requestIdto a main-framegetResponseBodycall. - `waitFor` will reject on timeout, not return
null. Wrap intry/catchif you don't want the whole snippet to fail when the request doesn't happen.
Print as PDF
Two completely different things the user may mean by "print to PDF":
1. Render the current page to a PDF (what you usually want)
const { data } = await session.Page.printToPDF({
printBackground: true,
paperWidth: 8.5, // inches
paperHeight: 11,
marginTop: 0.4,
marginBottom: 0.4,
marginLeft: 0.4,
marginRight: 0.4,
preferCSSPageSize: true, // respect @page in the site's CSS if set
})
// Cross-platform temp dir: /tmp on Linux, /var/folders/… on macOS, %TEMP% on Windows
const { tmpdir } = await import('node:os')
await Bun.write(`${tmpdir()}/page.pdf`, Buffer.from(data, 'base64'))Works without any visible print dialog — Chrome renders the PDF server-side in the process. Undetectable by the page.
Options worth knowing:
landscape: true— flip orientationdisplayHeaderFooter: true+headerTemplate/footerTemplate— printed HTML (mustache-style variables:{{pageNumber}},{{totalPages}},{{title}},{{url}})scale: 0.8— shrink to fitpageRanges: '1-3,7'— subset of pagestransferMode: 'ReturnAsStream'— for very large PDFs, returns a stream handle instead of a giant base64 blob
2. The site has a "Print" button that opens a real print dialog
Some sites call window.print() and rely on the user picking "Save as PDF" in the OS dialog. CDP cannot interact with the OS print dialog.
Two ways around it:
A. Intercept window.print before the click
await session.Runtime.evaluate({ expression: `
window.print = () => {
window.dispatchEvent(new Event('beforeprint'))
window.__printed__ = true
}
`})
// Click the site's Print button — the call is now a no-op
// Then generate the PDF yourself:Follow with session.Page.printToPDF(...) from option 1.
Detectable by window.print.toString() — fine for most sites, risky for antibot.
B. Use the underlying URL
Often the Print button just navigates to a print-friendly URL like /invoice/123?print=1. Find it with DevTools, then:
await session.Page.navigate({ url: 'https://example.com/invoice/123?print=1' })
// ...wait for load...
await session.Page.printToPDF({ printBackground: true })Traps
- `printBackground: false` (default) skips background colors and images. Invoices, receipts, and anything design-heavy look empty without it — turn it on unless you specifically want the "clean print" look.
- `Page.printToPDF` uses its own print-media CSS (
@media print). If the page hides elements withdisplay: noneunder@media print, they'll be missing from your PDF. Override withEmulation.setEmulatedMedia({ media: 'screen' })first. - Very large pages (long reports, data tables) can hit Chrome's internal PDF size limits and fail silently. Split by
pageRangesor reducescale. - Fonts may substitute. Chrome uses system fonts for PDF rendering — if the site uses a webfont that isn't loaded at capture time, your PDF gets the fallback.
Screenshots
session.Page.captureScreenshot is your default discovery and verification tool.
Core calls
// Viewport only (default) — fastest, matches what the user sees
const { data } = await session.Page.captureScreenshot({ format: 'png' })
// Cross-platform temp dir: /tmp on Linux, /var/folders/… on macOS, %TEMP% on Windows
const { tmpdir } = await import('node:os')
await Bun.write(`${tmpdir()}/shot.png`, Buffer.from(data, 'base64'))
// Full page — stitched beyond the viewport
await session.Page.captureScreenshot({ format: 'png', captureBeyondViewport: true })
// JPEG is ~5× smaller — good when you only need to eyeball
await session.Page.captureScreenshot({ format: 'jpeg', quality: 70 })
// A specific region (page coordinates)
await session.Page.captureScreenshot({
format: 'png',
clip: { x: 0, y: 0, width: 800, height: 600, scale: 1 },
})When to screenshot
- Discovery: after navigating, before inventing a selector. A screenshot answers "is the thing I need visible and where?" faster than a DOM walk.
- Verification: after every meaningful action. The DOM can lie about state; pixels cannot.
- Debugging coordinate clicks: shot → read →
Input.dispatchMouseEventat (x, y) → shot again.
Element screenshots via DOM.getBoxModel
When you want just one element:
await session.DOM.enable()
const { root } = await session.DOM.getDocument({})
const { nodeId } = await session.DOM.querySelector({ nodeId: root.nodeId, selector: '.card' })
const { model } = await session.DOM.getBoxModel({ nodeId })
const [x, y] = model.border // top-left
const width = model.width
const height = model.height
await session.Page.captureScreenshot({ clip: { x, y, width, height, scale: 1 } })model.border is [x1,y1, x2,y1, x2,y2, x1,y2] — 8 numbers, 4 corners. Take the first two for origin.
Traps
captureBeyondViewport: truere-layouts the page (fires resize). Don't use it in the middle of a user-driven flow — use viewport shots.- On high-DPI,
captureScreenshotreturns the device-pixel image. If you plan to coordinate-click on values read from the image, remember the CSS-pixel / device-pixel scale (see viewport.md). - Pages with fixed/sticky headers over
captureBeyondViewportcan produce duplicated headers down the stitched image.
Scrolling
Three levels, in order of how often they work:
1. Wheel event at a point — Input.dispatchMouseEvent { type: 'mouseWheel' }. Scrolls whichever element is under (x, y) and consumes wheel. 2. `scrollIntoView` on the element — Runtime.evaluate with a short JS snippet. Works for anything in the DOM you can querySelector. 3. Set `scrollTop` directly on the container — bypasses animations and snap.
Wheel (coordinate-based, closest to a real user)
// scroll down 300px at the center of the viewport
await session.Input.dispatchMouseEvent({
type: 'mouseWheel',
x: 600, y: 400,
deltaX: 0, deltaY: 300,
})
// scroll up
await session.Input.dispatchMouseEvent({ type: 'mouseWheel', x: 600, y: 400, deltaX: 0, deltaY: -300 })- Pick (x, y) over the element you want to scroll. If you wheel over a sticky header or a pinned sidebar, nothing happens.
- Virtualized lists (
react-window, TanStack Virtual): wheeling the container is the only reliable scroll;scrollIntoViewon a child row often no-ops because the row isn't yet mounted.
scrollIntoView (DOM-based)
await session.Runtime.evaluate({ expression: `
document.querySelector('[data-row-id="42"]')?.scrollIntoView({ block: 'center', behavior: 'instant' })
`})behavior: 'instant'avoids the animation round-trip and your next action landing on old coordinates.- Fails silently if the selector doesn't match — always verify with a screenshot.
scrollTop / scrollLeft (blunt but reliable)
await session.Runtime.evaluate({ expression: `
const el = document.querySelector('.list-scroll-container')
if (el) el.scrollTop = el.scrollHeight
`})Use when:
- The container has custom
overflow: autoand wheel events aren't reaching it. - You need to jump to absolute offsets (top, bottom, "row N × rowHeight").
Which container is consuming wheel events?
Sites with multiple nested scrollers (modals inside pages, lists inside cards) make "scroll the page" ambiguous. Find the actual scroller:
await session.Runtime.evaluate({
returnByValue: true,
expression: `
(() => {
const out = []
document.querySelectorAll('*').forEach(el => {
const s = getComputedStyle(el)
if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight)
out.push({ tag: el.tagName, cls: el.className, h: el.clientHeight, scroll: el.scrollHeight })
})
return out
})()
`,
})Traps
- `scroll-behavior: smooth` in CSS makes everything animate — your
Input.dispatchMouseEventfires immediately, but the next coordinate click lands before the scroll finishes. Either setbehavior: 'instant'onscrollIntoView, orawait new Promise(r => setTimeout(r, 400))after wheeling. - Re-read element rects after opening a dropdown / modal before coordinate-clicking. Layout shifts invalidate cached coords.
- Wheel on a trackpad fires dozens of small delta events. If a site's infinite-scroll sentinel requires momentum, a single
deltaY: 300call may not trigger it — send several smaller wheels in a loop.
Shadow DOM
Closed shadow roots are rare on the sites you're likely to automate. Most web components use open shadow roots — you can walk them from JS or use CDP's pierceShadow flag.
First try: coordinate clicks
Compositor-level clicks don't care about shadow roots. If you can see it in a screenshot, Input.dispatchMouseEvent can click it. This avoids all shadow-piercing entirely — reach for it first for buttons, links, and form triggers.
CDP path: pierceShadow
DOM.querySelector / DOM.querySelectorAll accept pierceShadow: true — one call crosses every open shadow boundary:
await session.DOM.enable()
const { root } = await session.DOM.getDocument({})
const { nodeId } = await session.DOM.querySelector({
nodeId: root.nodeId,
selector: 'my-button >>> .inner-label',
// Chrome has also historically accepted `pierceShadow: true`; on recent
// Chrome the `>>>` combinator in the selector pierces shadow roots directly.
})JS path: recursive walk through shadowRoot
More portable, works in any Chrome:
await session.Runtime.evaluate({
returnByValue: true,
expression: `
(() => {
function* walk(root) {
const stack = [root]
while (stack.length) {
const node = stack.pop()
if (!node) continue
yield node
if (node.shadowRoot) stack.push(...node.shadowRoot.children)
stack.push(...(node.children || []))
}
}
for (const el of walk(document.body)) {
if (el.matches?.('.target-class')) {
const r = el.getBoundingClientRect()
return { x: r.x + r.width/2, y: r.y + r.height/2 }
}
}
return null
})()
`,
})Use the returned {x, y} for Input.dispatchMouseEvent.
Setting a value inside a shadow-DOM input
Reaching the input is the hard part — setting the value is the same as any input:
await session.Runtime.evaluate({ expression: `
(() => {
const host = document.querySelector('my-form')
const input = host.shadowRoot.querySelector('input[name=email]')
input.focus()
input.value = 'hi@example.com'
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
})()
`})composed: true on the event lets it cross shadow boundaries — many web components listen on the host, not the internal input.
Traps
- Closed shadow roots (
{ mode: 'closed' }) cannot be walked from JS. Fall back to coordinate clicks +Input.insertText. Closed roots are rare — usually only password managers and some Google components. - `slot` content lives in the light DOM, not the shadow root. If your element has
<slot>…</slot>, the children you're looking for arehost.children, nothost.shadowRoot.children. - `::part()` / `::slotted()` CSS affects styling but has no DOM-query equivalent — you still traverse
shadowRoot. - Element screenshots via
DOM.getBoxModelwork even for shadow-DOM elements once you have thenodeId.
Tabs
Use CDP for control (attach, activate known targets, inspect). Use UI automation for visible order.
Pure CDP
// List page targets (filtered; chrome:// / devtools:// dropped)
const tabs = await listPageTargets()
// Create a new tab and route subsequent calls to it
const { targetId } = await session.Target.createTarget({ url: 'https://example.com' })
await session.use(targetId)
// Switch: route calls to another existing tab
await session.use(otherTargetId)
// Show this tab visibly in Chrome (different from `session.use` — which is CDP routing only)
await session.Target.activateTarget({ targetId })
// Close a tab
await session.Target.closeTarget({ targetId })
// What tab is session.use currently pointing at?
const { targetInfo } = await session.Target.getTargetInfo({ targetId })`session.use` is CDP-side routing; `Target.activateTarget` is Chrome-side focus. They are independent. If the user expects Chrome to visibly change, call activateTarget too.
Two things Target.createTarget quietly gets wrong
1. Race: `{ url }` in `createTarget` can resolve before navigation starts. If you then poll document.readyState, you'll see 'complete' for about:blank and move on. Safer:
const { targetId } = await session.Target.createTarget({ url: 'about:blank' })
await session.use(targetId)
await session.Page.enable()
await session.Page.navigate({ url: 'https://example.com' })
// now wait for Page.loadEventFired via session.waitFor2. New tab may open behind the active one. Add Target.activateTarget if the user needs to see it.
Visible tab-strip order (platform UI)
CDP's Target.getTargets returns an arbitrary order — not left-to-right.
macOS
tell application "Google Chrome"
set out to {}
set i to 1
repeat with t in every tab of front window
set end of out to {tab_index:i, tab_title:(title of t), tab_url:(URL of t)}
set i to i + 1
end repeat
return out
end telltell application "Google Chrome"
set active tab index of front window to 2
activate
end tellLinux
No AppleScript. Use xdotool, wmctrl, or desktop-environment scripting. The split is the same — CDP for attach/activate-by-id, window manager for visible ordering.
Traps
listPageTargets()already dropschrome://anddevtools://. If you callTarget.getTargetsraw, you must filter yourself, or you'll attach to a 1px omnibox popup.- If a page reports
innerWidth=0 innerHeight=0, you're probably attached to a non-window surface (omnibox popup, background tab that never rendered).
Uploads
Never simulate clicks on <input type="file"> — it opens the OS file picker, which CDP cannot dismiss. Set files directly via CDP instead.
The canonical path
await session.DOM.enable()
const { root } = await session.DOM.getDocument({ depth: -1 })
const { nodeId } = await session.DOM.querySelector({
nodeId: root.nodeId,
selector: 'input[type="file"]',
})
if (!nodeId) throw new Error('no file input found')
await session.DOM.setFileInputFiles({
nodeId,
files: ['/absolute/path/to/file.png'],
})- Paths must be absolute.
- Multiple files: pass an array — only works if the input has
multiple. - Fires
changeon the input just like a real selection would.
Hidden / off-screen file inputs
Sites commonly hide <input type="file"> (display:none, visibility:hidden, positioned off-screen) and expose a styled button that calls input.click(). DOM.setFileInputFiles works regardless of visibility — find the input directly, don't click the button:
// Works even for display:none / opacity:0 inputs
const { nodeIds } = await session.DOM.querySelectorAll({
nodeId: root.nodeId,
selector: 'input[type="file"]',
})If querySelector returns nodeId: 0, the input is inside a shadow root or iframe — see shadow-dom.md / iframes.md.
Drag-and-drop upload zones
React/Vue dropzones (react-dropzone, etc.) often only react to drop events and have no <input>. Two paths:
1. Find the hidden input — most dropzones still include one for accessibility. Inspect with document.querySelectorAll('input[type=file]') first. 2. Synthesize a DOM drop event with a DataTransfer containing your File:
await session.Runtime.evaluate({ awaitPromise: true, expression: `
(async () => {
const resp = await fetch('https://example.com/file.png')
const blob = await resp.blob()
const file = new File([blob], 'file.png', { type: 'image/png' })
const dt = new DataTransfer()
dt.items.add(file)
const target = document.querySelector('.dropzone')
for (const type of ['dragenter','dragover','drop']) {
target.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: dt }))
}
})()
`})Detectable by antibot — prefer path 1 if a hidden input exists.
Verifying the upload fired
Listen for the change on the input, or watch the network via Network.requestWillBeSent for the upload POST. A screenshot alone often won't show that the file attached — use the network trace.
Viewport
Coordinate clicks depend on viewport size; layouts depend on viewport size; a lot of flaky automation traces to a viewport that silently changed.
Read the current viewport
const { result } = await session.Runtime.evaluate({
returnByValue: true,
expression: `
JSON.stringify({
w: innerWidth, h: innerHeight,
sx: scrollX, sy: scrollY,
pw: document.documentElement.scrollWidth,
ph: document.documentElement.scrollHeight,
dpr: devicePixelRatio,
})
`,
})
const vp = JSON.parse(result.value)innerWidth/innerHeight is the CSS-pixel viewport — what coordinate clicks use. devicePixelRatio multiplies for actual screen pixels (captureScreenshot output dimensions).
Force a specific size (CSS pixels)
await session.Emulation.setDeviceMetricsOverride({
width: 1280,
height: 800,
deviceScaleFactor: 1, // 0 = use real DPR; set to 2 for retina-like
mobile: false,
})All subsequent Input.dispatchMouseEvent coordinates are in this 1280×800 space — pin it at the start of a session so coordinates stay stable.
Clear it back to the actual window size:
await session.Emulation.clearDeviceMetricsOverride()Mobile emulation
await session.Emulation.setDeviceMetricsOverride({
width: 390, height: 844,
deviceScaleFactor: 3,
mobile: true,
})
await session.Emulation.setTouchEmulationEnabled({ enabled: true })
await session.Network.setUserAgentOverride({
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
})Mobile triggers responsive breakpoints and enables touch events. Sites with @media (hover: hover) also flip their hover affordances off.
w=0 h=0 is a target problem, not a viewport problem
If Runtime.evaluate('innerWidth') returns 0, you're attached to a non-window surface (omnibox popup, a DevTools target). See connection.md / tabs.md — use listPageTargets() and re-route with session.use(...).
Traps
- Coordinate clicks become wrong as soon as the viewport changes. Re-read rects with
getBoundingClientRect()after any resize, not just after scrolling. - `captureScreenshot` returns device pixels, not CSS pixels. If
devicePixelRatio = 2and you eyeball an element at (400, 300) in the screenshot, click at (200, 150) in CSS pixels. - `setDeviceMetricsOverride` persists across navigations within the session — remember to clear it at the end if the user is going to keep using the browser.
- Some sites guard against resize storms (e.g.
window.addEventListener('resize', debounce)). AftersetDeviceMetricsOverride, wait ~300ms before reading rects or clicking. - Responsive sites that use `matchMedia` at page load may not re-evaluate breakpoints after override. Apply
setDeviceMetricsOverridebeforePage.navigate, not after.
MIT License
Copyright (c) 2026 Browser Use
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<img src="https://r2.browser-use.com/github/asbfgihsbfbaosfjla.png" alt="Browser Harness" width="100%" />
Browser Harness JS ♞
The thinnest possible bridge from the LLM to Chrome. No harness, no recipes, no rails — just every CDP method as a typed JS call.
One persistent WebSocket, 56 domains, 652 typed wrappers, zero wrapping of what Chrome already does.
● agent: wants to click a button
│
● no click() helper, no upload_file(), no goto()
│
● agent writes the CDP call itself await session.Input.dispatchMouseEvent({...})
│ await session.DOM.setFileInputFiles({...})
✓ done — same pattern for all 652 methodsThe protocol is the API. If Chrome can do it, you can call it.
Installation
npx skills add https://github.com/browser-use/browser-harness-js --skill cdpOr paste this into your agent — it'll install the skill, put the CLI on your PATH, and run a first task:
Run `npx skills add https://github.com/browser-use/browser-harness-js --skill cdp`, then
symlink `browser-harness-js` into a directory on my PATH, then use the cdp skill to drive
my browser: look at all the tabs I have open, group them by topic, and screenshot the most
interesting one.(The CLI auto-installs `bun` on first run if it's missing. Set BROWSER_HARNESS_SKIP_BUN_INSTALL=1 to opt out.)
If Chrome asks you to tick a remote-debugging checkbox, do it — that's how the agent attaches:
<img src="docs/setup-remote-debugging.png" alt="Remote debugging setup" width="520" style="border-radius: 12px;" />
See interaction-skills/ for recipes on the mechanics that are not obvious from the CDP method list alone.
Files
SKILL.md— day-to-day usage; how to connect, pick a tab, call methods, persist statesdk/browser-harness-js— tiny CLI that auto-spawns the server and forwards snippetssdk/repl.ts— Bun HTTP server holding one persistentSessionsdk/session.ts— theSessionclass: transport, connect, target routing, eventssdk/gen.ts— codegen: readsbrowser_protocol.json+js_protocol.json→ typed wrapperssdk/generated.ts— every CDP method assession.<Domain>.<method>(params)(generated)
No helpers file. No click(), no goto(), no upload_file() — just the protocol, typed.
Why no pre-baked helpers?
Every helper is a lie about what CDP already gives you. click(x, y) hides Input.dispatchMouseEvent — which has 14 parameters the LLM might need (button, clickCount, modifiers, pointerType, force, tangentialPressure, …). A harness that exposes three of them quietly limits what the agent can do.
- Types are the docs.
session.Page.navigate(triggers autocomplete with the exact params — same JSDoc as the CDP reference. - No version drift. The SDK is regenerated from the upstream protocol JSON; new Chrome methods appear as soon as you swap the JSON.
- No "helper doesn't handle my case" detours. If CDP can do it, the agent can call it — directly, typed, today.
The only "helpers" you'll find are things CDP itself is missing:
listPageTargets()— filterschrome:///devtools://out ofTarget.getTargetsresolveWsUrl({wsUrl|port|profileDir})— readsDevToolsActivePortfor Chrome 144+session.use(targetId)/session.waitFor(method, pred, timeout)— the two routing primitives you genuinely need
Contributing
PRs welcome. The best way to help: contribute a new interaction skill under interaction-skills/ when you figure out the CDP recipe for something non-obvious (a dropdown framework, a shadow-DOM trap, a network-wait pattern).
- Keep recipes in pure CDP —
session.Domain.method(...), not wrapped helpers. - Lead with the shortest method call that works; add the workaround or trap afterwards.
- Small and focused beats comprehensive. One mechanic per file.
- Bug fixes, codegen improvements, and
session.tsrefinements are equally welcome.
---
#!/usr/bin/env bash
# browser-harness-js — eval JS in the persistent CDP REPL. Auto-starts the REPL on first use.
#
# Usage:
# browser-harness-js 'await session.connect({port:9222})'
# browser-harness-js 'await session.Page.navigate({url:"https://example.com"})'
# browser-harness-js <<'EOF'
# const t = await listPageTargets("localhost", 9222);
# globalThis.tid = t[0].targetId;
# await session.use(globalThis.tid);
# globalThis.tid
# EOF
#
# browser-harness-js --status # is the REPL running? prints health JSON
# browser-harness-js --stop # gracefully shut it down
# browser-harness-js --logs # tail the REPL log
# browser-harness-js --restart # stop + start fresh (drops session state)
# browser-harness-js --start # explicit start (no-op if already running)
set -euo pipefail
PORT="${CDP_REPL_PORT:-9876}"
HOST="127.0.0.1"
URL="http://$HOST:$PORT"
# Resolve repl.ts alongside this script, following symlinks (e.g. /usr/local/bin/browser-harness-js → <skill-dir>/sdk/browser-harness-js).
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
done
REPL="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)/repl.ts"
LOG="${CDP_REPL_LOG:-/tmp/browser-harness-js.log}"
# Bootstrap bun if missing — the REPL server is Bun-native.
ensure_bun() {
if command -v bun >/dev/null 2>&1; then return 0; fi
# Handle fresh install: bun's install script drops the binary here but
# PATH isn't updated until the next login shell.
if [ -x "$HOME/.bun/bin/bun" ]; then
export PATH="$HOME/.bun/bin:$PATH"
return 0
fi
if [ -n "${BROWSER_HARNESS_SKIP_BUN_INSTALL:-}" ]; then
echo "browser-harness-js: bun not found and BROWSER_HARNESS_SKIP_BUN_INSTALL is set." >&2
echo " Install manually: curl -fsSL https://bun.sh/install | bash" >&2
return 1
fi
echo "browser-harness-js: installing bun (one-time, from https://bun.sh/install)..." >&2
if ! curl -fsSL https://bun.sh/install | bash >&2; then
echo "browser-harness-js: bun install failed. Install manually from https://bun.sh, or set BROWSER_HARNESS_SKIP_BUN_INSTALL=1 to suppress this prompt." >&2
return 1
fi
export PATH="$HOME/.bun/bin:$PATH"
command -v bun >/dev/null 2>&1 || {
echo "browser-harness-js: bun installed but not found at \$HOME/.bun/bin/bun." >&2
return 1
}
}
is_up() {
curl -fsS --max-time 1 "$URL/health" >/dev/null 2>&1
}
start_repl() {
is_up && return 0
ensure_bun || return 1
CDP_REPL_PORT="$PORT" nohup bun "$REPL" >"$LOG" 2>&1 &
for _ in $(seq 1 100); do
sleep 0.1
is_up && return 0
done
echo "browser-harness-js: REPL failed to start on $URL (see $LOG)" >&2
return 1
}
post_eval() {
# Capture body + status separately. Body goes to stdout (only if non-empty)
# on 200; otherwise to stderr with non-zero exit.
local out status body
out=$(curl -sS -w '\n___STATUS___%{http_code}' --data-binary "$1" "$URL/eval")
status="${out##*___STATUS___}"
body="${out%$'\n'___STATUS___*}"
if [ "$status" = "200" ]; then
[ -n "$body" ] && printf '%s\n' "$body"
return 0
else
[ -n "$body" ] && printf '%s\n' "$body" >&2
return 1
fi
}
case "${1:-}" in
--status)
if is_up; then
curl -sS "$URL/health"; echo
else
echo '{"ok":false,"error":"down"}'
exit 1
fi
;;
--start)
start_repl
curl -sS "$URL/health"; echo
;;
--stop)
if is_up; then
curl -s -X POST "$URL/quit" >/dev/null || true
echo '{"ok":true,"stopped":true}'
else
echo '{"ok":true,"stopped":false,"note":"already down"}'
fi
;;
--restart)
is_up && curl -s -X POST "$URL/quit" >/dev/null 2>&1 || true
sleep 0.2
start_repl
curl -sS "$URL/health"; echo
;;
--logs)
exec tail -f "$LOG"
;;
--help|-h)
sed -n '2,/^set -euo/p' "$0" | sed 's/^#//; s/^ //; /^set -euo/d'
;;
"")
start_repl
code="$(cat)"
post_eval "$code"
;;
*)
start_repl
post_eval "$1"
;;
esac
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "cdp-sdk",
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.5.0",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
}
}
#!/usr/bin/env bun
/**
* Codegen: reads browser_protocol.json + js_protocol.json, writes generated.ts
* with full TypeScript types and a `bindDomains(transport)` factory that
* returns `{ Page: { navigate(...), ... }, DOM: { ... }, ... }`.
*
* Skip events. Include experimental/deprecated. Skip redirected commands
* (the redirect target's domain has the canonical version).
*/
const HERE = import.meta.dir;
type Prop = {
name: string;
description?: string;
optional?: boolean;
type?: string;
$ref?: string;
items?: Prop;
properties?: Prop[];
enum?: string[];
};
type CdpType = {
id: string;
description?: string;
type?: string;
$ref?: string;
items?: Prop;
properties?: Prop[];
enum?: string[];
};
type Command = {
name: string;
description?: string;
experimental?: boolean;
deprecated?: boolean;
redirect?: string;
parameters?: Prop[];
returns?: Prop[];
};
type Domain = {
domain: string;
description?: string;
experimental?: boolean;
deprecated?: boolean;
types?: CdpType[];
commands?: Command[];
};
const RESERVED = new Set([
'this', 'class', 'function', 'enum', 'extends', 'super', 'import', 'export',
'default', 'new', 'delete', 'typeof', 'instanceof', 'void', 'null', 'true',
'false', 'in', 'of', 'do', 'if', 'else', 'switch', 'case', 'break', 'continue',
'return', 'while', 'for', 'try', 'catch', 'finally', 'throw', 'with',
'debugger', 'var', 'let', 'const',
]);
async function loadDomains(): Promise<Domain[]> {
const out: Domain[] = [];
for (const fn of ['browser_protocol.json', 'js_protocol.json']) {
const data = await Bun.file(`${HERE}/${fn}`).json();
out.push(...(data.domains as Domain[]));
}
out.sort((a, b) => a.domain.localeCompare(b.domain));
return out;
}
function escId(name: string): string {
if (RESERVED.has(name) || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) {
return JSON.stringify(name);
}
return name;
}
function jsdocLines(text: string | undefined, indent: string): string {
if (!text) return '';
// Escape `*/` so it doesn't close the JSDoc block early.
const safe = text.replace(/\*\//g, '*\\/');
const lines = safe.split(/\r?\n/);
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`;
return `${indent}/**\n${lines.map(l => `${indent} * ${l}`).join('\n')}\n${indent} */\n`;
}
/** Render a type (Prop or CdpType) to a TS type string in the context of `currentDomain`. */
function renderType(p: Prop | CdpType, currentDomain: string): string {
if (p.$ref) {
const ref = p.$ref;
if (ref.includes('.')) return ref; // qualified -> use as-is (Domain.Type)
return `${currentDomain}.${ref}`;
}
switch (p.type) {
case 'string':
if (p.enum) return p.enum.map(v => JSON.stringify(v)).join(' | ');
return 'string';
case 'integer':
case 'number':
return 'number';
case 'boolean':
return 'boolean';
case 'binary':
return 'string'; // base64-encoded
case 'any':
return 'unknown';
case 'array': {
const inner = p.items ? renderType(p.items as Prop, currentDomain) : 'unknown';
return `${inner}[]`;
}
case 'object': {
if (p.properties) return renderObject(p.properties, currentDomain, ' ');
return 'Record<string, unknown>';
}
default:
return 'unknown';
}
}
function renderObject(props: Prop[], currentDomain: string, indent: string): string {
const inner = props.map(pr => {
const opt = pr.optional ? '?' : '';
const t = renderType(pr, currentDomain);
return `${indent} ${escId(pr.name)}${opt}: ${t};`;
}).join('\n');
return `{\n${inner}\n${indent}}`;
}
/** Top-level type definition for a domain's `types[]`. */
function renderTypeDef(t: CdpType, currentDomain: string): string {
const doc = jsdocLines(t.description, ' ');
// Object type → interface
if (t.type === 'object' && t.properties) {
const body = t.properties.map(pr => {
const pdoc = jsdocLines(pr.description, ' ');
const opt = pr.optional ? '?' : '';
const ty = renderType(pr, currentDomain);
return `${pdoc} ${escId(pr.name)}${opt}: ${ty};`;
}).join('\n');
return `${doc} export interface ${t.id} {\n${body}\n }`;
}
// Enum string union
if (t.type === 'string' && t.enum) {
return `${doc} export type ${t.id} = ${t.enum.map(v => JSON.stringify(v)).join(' | ')};`;
}
// Otherwise alias (string/number/array/etc.)
const alias = renderType(t as unknown as Prop, currentDomain);
return `${doc} export type ${t.id} = ${alias};`;
}
/** Param/return interface. Returns the body (without surrounding interface keyword). */
function renderInterfaceBody(props: Prop[], currentDomain: string): string {
return props.map(pr => {
const pdoc = jsdocLines(pr.description, ' ');
const opt = pr.optional ? '?' : '';
const t = renderType(pr, currentDomain);
return `${pdoc} ${escId(pr.name)}${opt}: ${t};`;
}).join('\n');
}
function isAllOptional(props: Prop[] | undefined): boolean {
if (!props || props.length === 0) return true;
return props.every(p => p.optional);
}
async function build() {
const domains = await loadDomains();
// Skip empty/no-command domains? No — keep all so types resolve. But only
// bind methods for non-redirected commands.
const out: string[] = [];
out.push(`/* eslint-disable */\n// AUTO-GENERATED by gen.ts. Do not edit by hand.\n// Run \`bun gen.ts\` to regenerate from browser_protocol.json + js_protocol.json.\n`);
// Transport interface
out.push(`export interface Transport {\n _call(method: string, params?: unknown): Promise<unknown>;\n}\n`);
// ---- Type namespaces ----
for (const d of domains) {
out.push(`\nexport namespace ${d.domain} {`);
if (!d.types || d.types.length === 0) {
out.push(` // (no types)\n}`);
continue;
}
out.push('');
for (const t of d.types) {
out.push(renderTypeDef(t, d.domain));
out.push('');
}
out.push(`}`);
}
// ---- Param/return interfaces (also under domain namespaces) ----
// Strategy: for each command, emit `Domain.<Name>Params` and `Domain.<Name>Return`
// inside a second namespace block (TS allows merging multiple namespace decls).
for (const d of domains) {
if (!d.commands) continue;
const realCmds = d.commands.filter(c => !c.redirect);
if (realCmds.length === 0) continue;
out.push(`\nexport namespace ${d.domain} {`);
for (const c of realCmds) {
const capName = c.name.charAt(0).toUpperCase() + c.name.slice(1);
// Params
if (c.parameters && c.parameters.length > 0) {
const body = renderInterfaceBody(c.parameters, d.domain);
out.push(` export interface ${capName}Params {\n${body}\n }`);
} else {
out.push(` export interface ${capName}Params {}`);
}
// Return
if (c.returns && c.returns.length > 0) {
const body = renderInterfaceBody(c.returns, d.domain);
out.push(` export interface ${capName}Return {\n${body}\n }`);
} else {
out.push(` export type ${capName}Return = void;`);
}
}
out.push(`}`);
}
// ---- Domains interface (the shape of session.* domain bindings) ----
out.push(`\nexport interface Domains {`);
for (const d of domains) {
if (!d.commands) continue;
const realCmds = d.commands.filter(c => !c.redirect);
if (realCmds.length === 0) continue;
out.push(` ${d.domain}: {`);
for (const c of realCmds) {
const capName = c.name.charAt(0).toUpperCase() + c.name.slice(1);
const cdoc = jsdocLines(c.description, ' ');
const noParams = !c.parameters || c.parameters.length === 0;
const allOpt = isAllOptional(c.parameters);
const paramSig = noParams
? `()`
: allOpt
? `(params?: ${d.domain}.${capName}Params)`
: `(params: ${d.domain}.${capName}Params)`;
const retType = c.returns && c.returns.length > 0
? `${d.domain}.${capName}Return`
: 'void';
out.push(`${cdoc} ${escId(c.name)}: ${paramSig} => Promise<${retType}>;`);
}
out.push(` };`);
}
out.push(`}`);
// ---- bindDomains factory ----
out.push(`\nexport function bindDomains(t: Transport): Domains {\n return {`);
for (const d of domains) {
if (!d.commands) continue;
const realCmds = d.commands.filter(c => !c.redirect);
if (realCmds.length === 0) continue;
out.push(` ${d.domain}: {`);
for (const c of realCmds) {
const fq = `${d.domain}.${c.name}`;
out.push(` ${escId(c.name)}: (params?: any) => t._call(${JSON.stringify(fq)}, params) as any,`);
}
out.push(` },`);
}
out.push(` };\n}\n`);
// ---- Stats footer (for sanity) ----
const totalCmds = domains.reduce((n, d) => n + (d.commands?.filter(c => !c.redirect).length ?? 0), 0);
const totalTypes = domains.reduce((n, d) => n + (d.types?.length ?? 0), 0);
out.push(`// Stats: ${domains.length} domains, ${totalCmds} commands (excl. redirects), ${totalTypes} types\n`);
const text = out.join('\n');
const target = `${HERE}/generated.ts`;
await Bun.write(target, text);
console.log(`Wrote ${target} (${text.length.toLocaleString()} bytes)`);
console.log(`Domains: ${domains.length} | Commands: ${totalCmds} | Types: ${totalTypes}`);
}
await build();
{
"name": "cdp-sdk",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"gen": "bun gen.ts",
"repl": "bun repl.ts"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.5.0"
}
}
/**
* CDP REPL — HTTP server holding one persistent CDP Session.
*
* Endpoints (bind 127.0.0.1:9876 by default; override with $CDP_REPL_PORT):
* POST /eval body = raw JS to evaluate (NOT JSON-wrapped).
* Top-level await supported. Single expression auto-returns.
* Response: {"ok":true,"result":<json>} | {"ok":false,"error":..,"stack"?:..}
* GET /health {"ok":true,"uptime":<seconds>,"connected":<bool>,"sessionId":<string|null>}
* POST /quit graceful shutdown. Returns {"ok":true} then exits.
*
* State: `session`, the active sessionId, event subscribers, and any
* `globalThis.<name>` you set persist across requests for the lifetime of
* the process.
*/
import { Session, listPageTargets, resolveWsUrl, detectBrowsers } from './session.ts';
import * as Generated from './generated.ts';
const session = new Session();
(globalThis as any).session = session;
// Bind helpers to the singleton session so the agent calls `listPageTargets()`
// with no args (no host/port confusion, no /json endpoint assumption).
(globalThis as any).listPageTargets = () => listPageTargets(session);
(globalThis as any).resolveWsUrl = resolveWsUrl;
(globalThis as any).detectBrowsers = detectBrowsers;
(globalThis as any).CDP = Generated;
const PORT = Number(process.env.CDP_REPL_PORT ?? 9876);
const startedAt = Date.now();
function isExpression(code: string): boolean {
const trimmed = code.trim();
if (!trimmed) return false;
if (/[;\n]/.test(trimmed)) return false;
if (/^(let|const|var|if|for|while|do|switch|class|function|throw|try|return|import|export)\b/.test(trimmed)) return false;
return true;
}
function serialize(v: unknown): unknown {
if (v === undefined) return undefined;
try {
return JSON.parse(JSON.stringify(v, (_k, val) => typeof val === 'bigint' ? val.toString() : val));
} catch {
return String(v);
}
}
async function runSnippet(code: string): Promise<unknown> {
const body = isExpression(code) ? `return (${code});` : code;
const wrapped = `(async () => { ${body} })()`;
return await (0, eval)(wrapped);
}
const TEXT = { 'content-type': 'text/plain; charset=utf-8' } as const;
/**
* Render a value to the body of a successful /eval response.
* - undefined / null / "" / {} / [] → empty (caller prints nothing)
* - string → raw (no JSON quotes)
* - everything else → JSON
*/
function renderResult(v: unknown): string {
const s = serialize(v);
if (s === undefined || s === null) return '';
if (typeof s === 'string') return s;
if (Array.isArray(s) && s.length === 0) return '';
if (typeof s === 'object' && s !== null && Object.keys(s as object).length === 0) return '';
return JSON.stringify(s);
}
const server = Bun.serve({
port: PORT,
hostname: '127.0.0.1',
async fetch(req) {
const url = new URL(req.url);
if (req.method === 'GET' && url.pathname === '/health') {
return Response.json({
ok: true,
uptime: Math.floor((Date.now() - startedAt) / 1000),
connected: session.isConnected(),
sessionId: session.getActiveSession() ?? null,
});
}
if (req.method === 'POST' && url.pathname === '/eval') {
const code = await req.text();
if (!code.trim()) {
return new Response('empty body\n', { status: 400, headers: TEXT });
}
try {
const result = await runSnippet(code);
const body = renderResult(result);
return new Response(body, { status: 200, headers: TEXT });
} catch (e: any) {
const msg = (e?.stack ?? e?.message ?? String(e)) + '\n';
return new Response(msg, { status: 500, headers: TEXT });
}
}
if (req.method === 'POST' && url.pathname === '/quit') {
// Delay shutdown so the response flushes over the wire first.
setTimeout(() => { server.stop(true); session.close(); process.exit(0); }, 50);
return Response.json({ ok: true });
}
return new Response('not found', { status: 404 });
},
});
console.log(JSON.stringify({
ok: true,
ready: true,
port: server.port,
message: `CDP REPL listening on http://127.0.0.1:${server.port}`,
}));
/**
* CDP Session: one persistent WebSocket to Chrome's browser endpoint.
* Auto-injects sessionId for the active target on every call.
*
* Connect with `flatten: true` so all sessions share one WS (no nested
* Target.sendMessageToTarget envelopes).
*/
import { bindDomains, type Domains, type Transport } from './generated.ts';
type Pending = {
resolve: (v: unknown) => void;
reject: (e: unknown) => void;
};
export type ConnectOptions = {
/** Full WS URL: ws://host:port/devtools/browser/<id>. Escape hatch. */
wsUrl?: string;
/** Or: read DevToolsActivePort from a specific browser's profile dir. */
profileDir?: string;
/** Per-candidate WS-open timeout in ms. Default 5000.
* A live browser opens or 403s within ~100ms, so 5s is generous.
* The only case that legitimately needs longer is waiting on the Chrome
* "Allow" popup — bump to 30000 if you expect the user to click it. */
timeoutMs?: number;
};
/** A Chromium-based browser detected as running on this machine. */
export type DetectedBrowser = {
/** Short label, e.g. 'Google Chrome', 'Brave', 'Comet'. */
name: string;
/** Absolute profile (user-data) dir. */
profileDir: string;
/** Port from DevToolsActivePort line 1. */
port: number;
/** WebSocket path from DevToolsActivePort line 2. */
wsPath: string;
/** `ws://127.0.0.1:<port><wsPath>` — ready for WebSocket. */
wsUrl: string;
/** DevToolsActivePort mtime (ms since epoch). Used to order by recency. */
mtimeMs: number;
};
export class Session implements Transport {
private ws?: WebSocket;
private nextId = 1;
private pending = new Map<number, Pending>();
private activeSessionId: string | undefined;
private eventListeners: Array<(method: string, params: unknown, sessionId?: string) => void> = [];
// Generated bindings — one per CDP domain.
// Initialized lazily after construction so `_call` is available.
domains!: Domains;
constructor() {
this.domains = bindDomains(this);
// Mirror domains onto `this` so calls read as `session.Page.navigate(...)`.
for (const k of Object.keys(this.domains) as (keyof Domains)[]) {
(this as any)[k] = this.domains[k];
}
}
/**
* Connect to Chrome's browser-level WebSocket.
*
* With no args, runs auto-detect: scans OS-specific profile dirs via
* `detectBrowsers()` and tries each candidate (most-recently-launched first)
* until a WebSocket open succeeds. Each attempt has a short timeout so
* dead ports and permission-denied (403) candidates fail fast and the
* loop moves on.
*
* With explicit opts ({ wsUrl } | { profileDir } | { port }), connects
* directly to that single URL with a generous timeout.
*/
async connect(opts: ConnectOptions = {}): Promise<void> {
const timeoutMs = opts.timeoutMs ?? 5_000;
if (opts.wsUrl || opts.profileDir) {
const wsUrl = await resolveWsUrl(opts);
await this.openWs(wsUrl, timeoutMs);
return;
}
const browsers = await detectBrowsers();
if (browsers.length === 0) {
const scanned = getBrowserCandidates().map(c => c.name).join(', ');
throw new Error(
`No running browser with remote debugging detected. Enable it from chrome://inspect > "Discover network targets", or pass { profileDir } / { wsUrl } explicitly. Scanned: ${scanned}.`,
);
}
const errors: string[] = [];
for (const b of browsers) {
try {
await this.openWs(b.wsUrl, timeoutMs);
return;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
errors.push(` ${b.name} @ ${b.wsUrl}: ${msg}`);
}
}
throw new Error(
`No detected browser accepted a connection. If one of these is the browser you want, click "Allow" on its remote-debugging prompt and retry, or pass { profileDir, timeoutMs: 30000 } to wait for the click:\n${errors.join('\n')}`,
);
}
private openWs(wsUrl: string, timeoutMs: number): Promise<void> {
return new Promise<void>((res, rej) => {
const ws = new WebSocket(wsUrl);
let done = false;
const finish = (err?: Error) => {
if (done) return;
done = true;
clearTimeout(timer);
if (err) { try { ws.close(); } catch { /* ignore */ } rej(err); }
else res();
};
const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
ws.addEventListener('open', () => finish());
ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`)));
ws.addEventListener('message', (e) => this.onMessage(String(e.data)));
ws.addEventListener('close', () => {
for (const [, p] of this.pending) p.reject(new Error('CDP socket closed'));
this.pending.clear();
finish(new Error('WS closed before open (likely 403 or port closed)'));
});
this.ws = ws;
});
}
isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
close(): void {
this.ws?.close();
}
/**
* Pick a target and make subsequent calls auto-route to it.
* Uses Target.attachToTarget with flatten:true (single-WS, sessionId-on-message).
*/
async use(targetId: string): Promise<string> {
const r = await this._call('Target.attachToTarget', { targetId, flatten: true }) as { sessionId: string };
this.activeSessionId = r.sessionId;
return r.sessionId;
}
/** Set the active sessionId directly (e.g. one you already attached). */
setActiveSession(sessionId: string | undefined): void {
this.activeSessionId = sessionId;
}
getActiveSession(): string | undefined {
return this.activeSessionId;
}
/** Subscribe to all CDP events. Returns an unsubscribe fn. */
onEvent(fn: (method: string, params: unknown, sessionId?: string) => void): () => void {
this.eventListeners.push(fn);
return () => {
this.eventListeners = this.eventListeners.filter(x => x !== fn);
};
}
/** Wait for the next event matching `method` (and optional predicate). */
waitFor<T = unknown>(method: string, predicate?: (params: T) => boolean, timeoutMs = 30_000): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
unsub();
reject(new Error(`Timeout waiting for ${method}`));
}, timeoutMs);
const unsub = this.onEvent((m, params) => {
if (m !== method) return;
if (predicate && !predicate(params as T)) return;
clearTimeout(timer);
unsub();
resolve(params as T);
});
});
}
// Transport implementation. Called by the generated domain bindings.
_call(method: string, params: unknown = {}): Promise<unknown> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error('Not connected. Call session.connect(...) first.'));
}
const id = this.nextId++;
const msg: Record<string, unknown> = { id, method, params: params ?? {} };
if (this.activeSessionId && !isBrowserLevel(method)) {
msg.sessionId = this.activeSessionId;
}
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.ws!.send(JSON.stringify(msg));
});
}
private onMessage(raw: string): void {
let m: any;
try { m = JSON.parse(raw); } catch { return; }
if (typeof m.id === 'number') {
const p = this.pending.get(m.id);
if (!p) return;
this.pending.delete(m.id);
if (m.error) p.reject(new CdpError(m.error.code, m.error.message, m.error.data));
else p.resolve(m.result);
} else if (m.method) {
for (const fn of this.eventListeners) {
try { fn(m.method, m.params, m.sessionId); } catch { /* ignore */ }
}
}
}
}
export class CdpError extends Error {
constructor(public code: number, message: string, public data?: unknown) {
super(`CDP ${code}: ${message}`);
this.name = 'CdpError';
}
}
/** Browser-level methods never take a sessionId. */
function isBrowserLevel(method: string): boolean {
return method.startsWith('Browser.') || method.startsWith('Target.');
}
/**
* Resolve a WebSocket URL for one of the explicit connect forms:
* { wsUrl } — passthrough.
* { profileDir } — reads `<profileDir>/DevToolsActivePort` and builds the
* WS URL directly. Works on all Chrome versions including
* 144+ / chrome://inspect (which doesn't serve /json/version).
*
* For auto-detect, call `session.connect()` with no args — it iterates
* `detectBrowsers()` and picks the first browser whose WS accepts.
*/
export async function resolveWsUrl(opts: ConnectOptions): Promise<string> {
if (opts.wsUrl) return opts.wsUrl;
if (opts.profileDir) {
const { port, path } = await readDevToolsActivePort(opts.profileDir);
return `ws://127.0.0.1:${port}${path}`;
}
throw new Error('resolveWsUrl needs { wsUrl } or { profileDir }. For auto-detect, call session.connect() directly.');
}
/**
* Parse both lines of DevToolsActivePort. Chrome writes:
* line 1: port number
* line 2: path (e.g. "/devtools/browser/<uuid>")
* With both in hand we can build `ws://host:port<path>` with no HTTP probe.
*/
async function readDevToolsActivePort(profileDir: string): Promise<{ port: number; path: string }> {
const deadline = Date.now() + 30_000;
let lastErr: unknown;
while (Date.now() < deadline) {
try {
const text = (await Bun.file(`${profileDir}/DevToolsActivePort`).text()).trim();
const [portStr, path] = text.split('\n');
const port = Number(portStr);
if (!Number.isFinite(port)) throw new Error(`malformed port line: ${portStr}`);
if (!path || !path.startsWith('/devtools/')) {
// File is written atomically but path line may not be there on first open.
throw new Error(`missing/invalid path line in DevToolsActivePort: ${JSON.stringify(text)}`);
}
return { port, path };
} catch (e) {
lastErr = e;
await Bun.sleep(250);
}
}
throw new Error(`Could not read ${profileDir}/DevToolsActivePort after 30s: ${lastErr}`);
}
/**
* List page targets via CDP's `Target.getTargets` (works on all Chrome versions,
* including those that do not serve /json). Filters out chrome:// and devtools://
* internals. Requires the session to be connected already.
*/
export type PageTarget = { targetId: string; title: string; url: string; type: string };
export async function listPageTargets(session: Session): Promise<PageTarget[]> {
const { targetInfos } = await session.domains.Target.getTargets({});
return (targetInfos as PageTarget[]).filter(
t => t.type === 'page' && !t.url.startsWith('chrome://') && !t.url.startsWith('devtools://')
);
}
/**
* Scan OS-specific user-data directories for Chromium-based browsers that
* currently have remote debugging enabled (a `DevToolsActivePort` file exists
* in the profile dir). Does NOT verify the WS endpoint is live — call
* `verifyWsEndpoint(wsUrl)` on each entry if you need that.
*
* Ordered by DevToolsActivePort mtime descending, so the most-recently-
* launched browser is first — that's the one `connect()` picks by default.
*
* This is the ONLY reliable connect method for Chrome 144+ with remote
* debugging toggled from chrome://inspect — those browsers do NOT serve
* `/json/version`, so port-probe discovery fails.
*/
export async function detectBrowsers(): Promise<DetectedBrowser[]> {
const candidates = getBrowserCandidates();
const detected: DetectedBrowser[] = [];
for (const { name, profileDir } of candidates) {
const parsed = await tryReadDevToolsActivePort(profileDir);
if (!parsed) continue;
detected.push({
name,
profileDir,
port: parsed.port,
wsPath: parsed.path,
wsUrl: `ws://127.0.0.1:${parsed.port}${parsed.path}`,
mtimeMs: parsed.mtimeMs,
});
}
detected.sort((a, b) => b.mtimeMs - a.mtimeMs);
return detected;
}
type BrowserCandidate = { name: string; profileDir: string };
/** OS-specific user-data dirs for Chromium-based browsers, in rough popularity order. */
function getBrowserCandidates(): BrowserCandidate[] {
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
const list: BrowserCandidate[] = [];
const push = (name: string, profileDir: string) => list.push({ name, profileDir });
if (process.platform === 'darwin') {
const base = `${home}/Library/Application Support`;
push('Google Chrome', `${base}/Google/Chrome`);
push('Chromium', `${base}/Chromium`);
push('Microsoft Edge', `${base}/Microsoft Edge`);
push('Brave', `${base}/BraveSoftware/Brave-Browser`);
push('Arc', `${base}/Arc/User Data`);
push('Vivaldi', `${base}/Vivaldi`);
push('Opera', `${base}/com.operasoftware.Opera`);
push('Comet', `${base}/Comet`);
push('Google Chrome Canary', `${base}/Google/Chrome Canary`);
} else if (process.platform === 'linux') {
const cfg = `${home}/.config`;
push('Google Chrome', `${cfg}/google-chrome`);
push('Chromium', `${cfg}/chromium`);
push('Microsoft Edge', `${cfg}/microsoft-edge`);
push('Brave', `${cfg}/BraveSoftware/Brave-Browser`);
push('Vivaldi', `${cfg}/vivaldi`);
push('Opera', `${cfg}/opera`);
push('Google Chrome Canary', `${cfg}/google-chrome-unstable`);
} else if (process.platform === 'win32') {
const local = process.env.LOCALAPPDATA ?? `${home}\\AppData\\Local`;
push('Google Chrome', `${local}\\Google\\Chrome\\User Data`);
push('Chromium', `${local}\\Chromium\\User Data`);
push('Microsoft Edge', `${local}\\Microsoft\\Edge\\User Data`);
push('Brave', `${local}\\BraveSoftware\\Brave-Browser\\User Data`);
push('Arc', `${local}\\Arc\\User Data`);
push('Vivaldi', `${local}\\Vivaldi\\User Data`);
push('Opera', `${local}\\Opera Software\\Opera Stable`);
push('Google Chrome Canary', `${local}\\Google\\Chrome SxS\\User Data`);
}
return list;
}
/**
* Read and parse `<profileDir>/DevToolsActivePort` once (no polling), returning
* undefined if the file is missing or malformed. Also returns mtime so callers
* can sort by recency.
*/
async function tryReadDevToolsActivePort(
profileDir: string,
): Promise<{ port: number; path: string; mtimeMs: number } | undefined> {
try {
const file = Bun.file(`${profileDir}/DevToolsActivePort`);
const [text, mtimeMs] = await Promise.all([file.text(), file.lastModified]);
const [portStr, path] = text.trim().split('\n');
const port = Number(portStr);
if (!Number.isFinite(port)) return undefined;
if (!path || !path.startsWith('/devtools/')) return undefined;
return { port, path, mtimeMs: mtimeMs as number };
} catch {
return undefined;
}
}
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"lib": ["ESNext", "DOM"],
"types": ["bun"]
},
"include": ["**/*.ts"]
}
Related skills
How it compares
Choose cdp for CDP-level agent browser harnessing in browser-harness-js rather than general documentation or static fetch utilities.
FAQ
What protocol does the cdp skill use?
The cdp skill uses the Chrome DevTools Protocol to give coding agents safe, reliable control of a real Chromium browser for navigation, DOM interaction, and automated web workflows inside browser-harness-js.
How many installs does cdp report on skills.sh?
The cdp skill reports 490 installs on skills.sh and ranks #5 among skills in the browser-use/browser-harness-js repository, reflecting active agent-browser automation usage.