| name | playwright-cdp-browser-access |
| description | Use Playwright to connect to an existing Chrome or Chromium browser via Chrome DevTools Protocol (CDP) browser instance. Use when the user needs browser automation, screenshots, page evaluation, or DOM interaction against a browser (running or not). Uses a temp Node project in /tmp/playwright-cdp/. |
Playwright CDP Browser Access
Automate a Chrome/Chromium browser via CDP using Playwright — connect to an existing instance or launch one on demand.
Before you start
Run <SKILL_DIR>/cdp-launch — it installs playwright-core if needed, then connects to an existing browser or launches one:
$SKILL_DIR/cdp-launch
$SKILL_DIR/cdp-launch --port 9333
$SKILL_DIR/cdp-launch --headed
$SKILL_DIR/cdp-launch --browser /usr/bin/chromium
$SKILL_DIR/cdp-launch --profile /tmp/my-profile
$SKILL_DIR/cdp-launch --timeout 15
Prints the CDP URL (e.g. http://127.0.0.1:9222) and exits 0 when ready. Exits 1 if launch fails.
Use 127.0.0.1 not localhost — Node may resolve localhost to IPv6 ::1 which Chromium doesn't bind.
Then write scripts that connect to http://127.0.0.1:9222.
How to write a script
require('playwright-core')
chromium.connectOverCDP('http://127.0.0.1:9222')
- Pick a context and page (or create new ones)
- Automate
- Close the browser
Minimal example
cd /tmp/playwright-cdp && node -e "
const { chromium } = require('playwright-core');
(async () => {
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222');
const context = browser.contexts()[0];
const page = context.pages()[0];
await page.goto('https://example.com');
await page.waitForTimeout(1000);
await browser.close();
})();
"
Common actions
Assuming page is already set up (see How to write a script):
await page.goto('https://example.com');
await page.click('button.submit');
await page.dblclick('#item');
await page.type('input[name="q"]', 'hello world');
await page.fill('input[name="email"]', 'user@example.com');
await page.keyboard.press('Enter');
await page.keyboard.press('Control+a');
await page.hover('#menu-trigger');
await page.focus('input[name="search"]');
await page.check('#agree');
await page.uncheck('#newsletter');
await page.selectOption('select#country', 'US');
await page.selectOption('select#country', { label: 'United States' });
await page.dragAndDrop('#source', '#target');
await page.setInputFiles('input[type="file"]', '/path/to/file.pdf');
await page.setInputFiles('input[type="file"]', ['/a.pdf', '/b.pdf']);
const [download] = await Promise.all([
page.waitForEvent('download'),
page.click('#download-btn'),
]);
await download.saveAs('/tmp/file.csv');
await page.evaluate(() => window.scrollBy(0, 500));
await page.evaluate(() => window.scrollBy(0, -500));
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.locator('#footer').scrollIntoViewIfNeeded();
await page.waitForSelector('.result');
await page.waitForTimeout(2000);
await page.waitForNavigation();
await page.waitForURL('**/dashboard');
await page.waitForFunction(() => document.readyState === 'complete');
await page.screenshot({ path: '/tmp/shot.png' });
await page.screenshot({ path: '/tmp/full.png', fullPage: true });
await page.pdf({ path: '/tmp/page.pdf', format: 'A4' });
const title = await page.evaluate(() => document.title);
const count = await page.evaluate(() => document.querySelectorAll('.item').length);
Working with existing tabs
connectOverCDP attaches to the browser, not a single tab. Use context.pages() to list tabs, .find() to locate one by URL, or context.newPage() to create one:
const context = browser.contexts()[0];
const pages = context.pages();
for (const [i, p] of pages.entries()) console.log(i, p.url(), await p.title());
const newTab = await context.newPage();
await newTab.goto('https://example.com', { timeout: 15000 });
const existing = context.pages().find(p => p.url().includes('example.com'));
if (!existing) throw new Error('Tab not found');
Why not connect directly to a tab WS? CDP does expose per-tab endpoints (ws://.../devtools/page/<id>), but Playwright's connectOverCDP expects a browser endpoint and returns a Browser object. Use the browser endpoint and select from pages().
Troubleshooting
Browser fails to launch (non-headless) on Wayland
If you see Vulkan/Wayland errors like:
ERROR:ui/ozone/platform/wayland/gpu/wayland_surface_factory.cc:252]
'--ozone-platform=wayland' is not compatible with Vulkan.
This is usually non-fatal — the browser may still start successfully. If it doesn't, add --ozone-platform=x11 to the launch flags:
$BROWSER \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-cdp-profile \
--ozone-platform=x11 \
...
If X11 is also unavailable (e.g. headless server), use --headless (already the recommended default above).
Port 9222 already in use
If the CDP launch fails, check what's holding the port:
ss -tlnp | grep 9222
lsof -i :9222
Then pkill -f "chrom.*9222" and retry.
networkidle never resolves on heavy sites
Modern sites (news portals, social media, finance) maintain persistent connections for ads, analytics, and live updates — waitUntil: 'networkidle' can hang indefinitely. Use domcontentloaded with a generous timeout instead:
await page.goto('https://yahoo.com', {
waitUntil: 'domcontentloaded',
timeout: 15000
});
await page.waitForTimeout(3000);
If even domcontentloaded times out (ad scripts or CDN stalls can block it), fall back to 'commit' and wait explicitly:
await page.goto('https://yahoo.com', { waitUntil: 'commit', timeout: 30000 });
await page.waitForLoadState('load', { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(5000);
'commit' resolves as soon as the server responds (HTTP 200). Then wait for load (best-effort) and give JS rendering time. The page is ready even if the load event never fires.
Browser fails on restricted environments
On some Linux/NixOS environments the Chromium sandbox prevents the browser from starting. Pass --no-sandbox to cdp-launch:
$SKILL_DIR/cdp-launch --no-sandbox
Notes
playwright-core is used instead of playwright to avoid downloading ~100 MB of browser binaries. We connect to an existing browser, so we do not need them.
connectOverCDP attaches to the browser. Calling browser.close() disconnects from the browser and cleans up Playwright-owned resources — but does not kill the browser process. Omit browser.close() if you want the Playwright connection to persist across script runs.
browser.contexts()[0] gets the default browser context. context.pages()[0] gets the first open tab. These may not exist if the browser has no tabs; handle with || await context.newPage().