
execute tool. One
tool, any Playwright code, no wrappers. Low context usage because there's no schema bloat from dozens of
tool definitions. And it runs in your existing browser, so nothing extra gets spawned.1npm i -g playwriter
1npx -y skills add remorses/playwriter
localhost:19988. The CLI sends Playwright code through the relay. No remote servers, no
accounts, nothing leaves your machine.1234playwriter session new # new sandbox, outputs id (e.g. 1) playwriter -e "page.goto('https://example.com')" playwriter -e "snapshot({ page })" playwriter -e "page.locator('aria-ref=e5').click()"
chrome.debugger and opens a
WebSocket to a local relay. Your agent (CLI, MCP, or a Playwright script) connects to the same relay.
CDP commands flow through; the extension forwards them to Chrome and sends responses back. No
Chrome restart, no flags, no special setup.┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │ BROWSER │ │ LOCALHOST │ │ CLIENT │ │ │ │ │ │ │ │ ┌───────────────┐ │ │ WebSocket Server │ │ ┌───────────┐ │ │ │ Extension │<───────┬───> :19988 │ │ │ CLI / MCP │ │ │ └───────┬───────┘ │ WS │ │ │ └───────────┘ │ │ │ │ │ /extension │ │ │ │ │ chrome.debugger │ │ │ │ │ v │ │ v │ │ v │ │ ┌────────────┐ │ │ ┌───────────────┐ │ │ /cdp/:id <───────────────>│ │ execute │ │ │ │ Tab 1 (green) │ │ └──────────────────────┘ WS │ └────────────┘ │ │ │ Tab 2 (green) │ │ │ │ │ │ │ Tab 3 (gray) │ │ Tab 3 not controlled │ Playwright API │ └─────────────────────┘ (extension not clicked) └─────────────────┘
12345678playwriter -e "snapshot({ page })" # Output: # - banner: # - link "Home" [id="nav-home"] # - navigation: # - link "Docs" [data-testid="docs-link"] # - link "Blog" role=link[name="Blog"]
page.locator().
Subsequent calls return a diff, so you only see what changed. Use search to
filter large pages.12345# Search for specific elements playwriter -e "snapshot({ page, search: /button|submit/i })" # Always print URL first, then snapshot playwriter -e "console.log('URL:', page.url()); snapshot({ page }).then(console.log)"
screenshotWithAccessibilityLabels overlays Vimium-style labels on every
interactive element. The agent sees the screenshot, reads the labels, and clicks by reference.1234playwriter -e "screenshotWithAccessibilityLabels({ page })" # Returns screenshot + accessibility snapshot with aria-ref selectors playwriter -e "page.locator('aria-ref=e5').click()"
snapshot(), so you can switch between text and visual modes freely.state object. Variables, pages, and listeners persist between
calls. Browser tabs are shared, but state is not.123456789playwriter session new # => 1 playwriter session new # => 2 playwriter session list # shows sessions + state keys # Session 1 stores data playwriter -s 1 -e "state.users = page.$$eval('.user', els => els.map(e => e.textContent))" # Session 2 can't see it playwriter -s 2 -e "console.log(state.users)" # undefined
about:blank tab or create a fresh one, and store it in state.1234playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? context.newPage(); state.myPage.goto('https://example.com')" # All subsequent calls use state.myPage playwriter -s 1 -e "state.myPage.title()"
12345678# Set breakpoints and debug playwriter -e "state.cdp = getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); state.dbg.enable()" playwriter -e "state.scripts = state.dbg.listScripts({ search: 'app' }); state.scripts.map(s => s.url)" playwriter -e "state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })" # Live edit page code playwriter -e "state.editor = createEditor({ cdp: state.cdp }); state.editor.enable()" playwriter -e "state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
grep across all loaded scripts.state and persists across calls.123456789# Start intercepting playwriter -e "state.responses = []; page.on('response', async res => { if (res.url().includes('/api/')) { try { state.responses.push({ url: res.url(), status: res.status(), body: await res.json() }); } catch {} } })" # Trigger actions, then analyze playwriter -e "page.click('button.load-more')" playwriter -e "console.log('Captured', state.responses.length, 'API calls'); state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))" # Replay an API call directly playwriter -e "page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url)"
chrome.tabCapture and runs in the extension context, so it
survives page navigation.123456789# Start recording playwriter -e "startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" # Navigate, interact — recording continues playwriter -e "page.click('a'); page.waitForLoadState('domcontentloaded')" playwriter -e "page.goBack()" # Stop and save playwriter -e "stopRecording({ page })"
getDisplayMedia, this approach persists across navigations because the extension holds
the MediaRecorder, not the page. You can also check recording status with isRecording or cancel
without saving with cancelRecording.1234567# On the host machine — start relay with tunnel npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret> # From anywhere — set env vars and use normally export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN=<secret> playwriter -e "page.goto('https://example.com')"
PLAYWRITER_HOST=192.168.1.10. Works for MCP too: set PLAYWRITER_HOST and
PLAYWRITER_TOKEN in your MCP client env config. Use cases: headless Mac mini, remote user
support, multi-machine automation, dev from a VM or devcontainer.localhost:19988 and only
accepts connections from the extension. No remote server, no account, no telemetry.