Chrome extension & CLI to let agents control your browser. Runs Playwright snippets in a stateful sandbox. Available as CLI or MCP
See the code
Let your agents control your own Chrome, via CLI or MCP. Your logins, extensions, cookies — already there.
Other browser MCPs spawn a fresh Chrome — no logins, no extensions, instantly flagged by bot detectors, double the memory. Playwriter connects to your running browser instead. One Chrome extension, full Playwright API, everything you're already logged into.
Install Extension from Chrome Web Store
Click extension icon on a tab → turns green when connected
Install the CLI and start automating the browser:
npm i -g playwriter
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
Install the skill so your agent knows how to use Playwriter:
npx -y skills add https://playwriter.dev
playwriter browser start # starts Chrome for Testing/Chromium with bundled Playwriter extension
playwriter session new # creates stateful sandbox, outputs session id (e.g. 1)
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
playwriter -s 1 -e 'console.log(await snapshot({ page: state.page }))'
playwriter -s 1 -e 'await state.page.locator("aria-ref=e5").click()'
Tip: Always use single quotes for
-eto prevent bash from interpreting$, backticks, and\in your JS code. Use double quotes for strings inside the JS.
Each session has isolated state. Browser tabs are shared across sessions.
# Browser management
playwriter browser start # auto-finds Chrome for Testing or Chromium, with recording flags enabled
playwriter browser start /path/to/browser-binary
# Session management
playwriter session new # creates stateful sandbox, outputs id (e.g. 1)
playwriter session new --tab-group agent1 --tab-group-color blue
playwriter session update 1 --tab-group research
playwriter session list # show sessions + state keys + group
playwriter session reset <id> # fix connection issues
# Execute (always use -s)
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
playwriter -s 1 -e 'await state.page.click("button")'
playwriter -s 1 -e 'console.log(await state.page.title())'
There is no default page. Create your own tab and store it in state. To use a tab the user already opened, find it by URL:
playwriter -s 1 -e 'state.page = context.pages().findLast((p) => p.url().includes("github.com"))'
Local extension sessions use a Chrome tab group named playwriter by default. Remote-control sessions put the tabs they open into remote. Use the shortest clear single-word name with no spaces, such as docs, shop, test, or scrape.
# Park a long scrape in its own group. The user can Move group to new window
# (or another screen). New tabs from this session follow that group.
playwriter session new --tab-group scrape --tab-group-color grey
# Split concurrent agents so many open tabs stay readable
playwriter session new --tab-group agent1 --tab-group-color blue
playwriter session new --tab-group agent2 --tab-group-color pink
# Name a group the user can collapse when they don't care about it
playwriter session new --tab-group done --tab-group-color grey
# Rename or recolor later
playwriter session update 1 --tab-group research
playwriter session update 1 --tab-group-color red
# Remote tabs support the same title and color options
playwriter session new --remote <id> --tab-group support
playwriter session update 1 --tab-group review --tab-group-color cyan
--tab-group-color accepts: grey, blue, red, yellow, green, pink, purple, cyan, orange. Without it, color is derived from the name. The default local playwriter group stays green.
Node programs should use connectViaExtension() instead of posting /cli/session/new themselves:
import { connectViaExtension } from 'playwriter'
await using connection = await connectViaExtension({
tabGroup: 'email-check',
tabGroupColor: 'grey',
})
const page = await connection.browser.contexts()[0].newPage()
await page.goto('https://example.com')
// connection closes automatically when the scope ends, also on throw
tabGroupColor is typed as Chrome's tab group colors. await using calls close() at scope end: it closes the tabs this connection opened (never other sessions' tabs), disconnects CDP, and deletes the session. Without await using, call await connection.close() in finally.
Pass closeTabsOnEnd: false to keep the opened tabs after close(). A later process can reuse them:
// process A
await using a = await connectViaExtension({ tabGroup: 'job', closeTabsOnEnd: false })
await (await a.browser.contexts()[0].newPage()).goto('https://example.com')
// process B, later
await using b = await connectViaExtension({ tabGroup: 'job' })
const page = b.browser.contexts()[0].pages().findLast((p) => p.url().includes('example.com'))
Tabs that b did not open are never closed by b.close().
If the extension runs in more than one browser or profile, pass selectBrowser. It gets every connected browser and returns the one to use:
await using connection = await connectViaExtension({
selectBrowser: (browsers) => {
// [{ id, browser: 'Brave', email: 'me@work.com', activeTabs, playwriterVersion }]
return browsers.find((b) => b.email === 'me@work.com')
},
})
id is stable, so you can save it and match on it later. listBrowsers() returns the same list without connecting.
Multiline:
playwriter -s 1 -e $'
const title = await state.page.title();
console.log({ title, url: state.page.url() });
'
Variables in scope: context, state (persists between calls), cloud (local CLI sessions), require, importModule, native import(), and Node.js globals. Relative imports resolve from the session working directory. There is no default page; examples below assume state.page was set as shown above.
Persist data in state:
playwriter -e "state.users = await state.page.$$eval('.user', els => els.map(e => e.textContent))"
playwriter -e "console.log(state.users)"
Intercept network requests:
playwriter -e "state.requests = []; state.page.on('response', r => { if (r.url().includes('/api/')) state.requests.push(r.url()) })"
playwriter -e "await Promise.all([state.page.waitForResponse(r => r.url().includes('/api/')), state.page.click('button')])"
playwriter -e "console.log(state.requests)"
Set breakpoints and debug:
playwriter -e "state.cdp = await getCDPSession({ page: state.page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
playwriter -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
playwriter -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })"
Live edit page code:
playwriter -e "state.cdp = await getCDPSession({ page: state.page }); state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()"
playwriter -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
Screenshot with labels:
playwriter -e "await screenshotWithAccessibilityLabels({ page: state.page })"
Live stream a tab to X Live / Twitch (RTMP, runs 24/7):
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
playwriter stream start -s 1 --rtmp rtmp://va.pscp.tv:80/x/<stream-key>
playwriter stream status -s 1
playwriter stream stop -s 1
Using the CLI with the skill (step 4 above) is the recommended approach. For direct MCP server configuration, see MCP.md.
Vimium-style labels for AI agents to identify elements:
await screenshotWithAccessibilityLabels({ page: state.page })
// Returns screenshot + accessibility snapshot with aria-ref selectors
await state.page.locator('aria-ref=e5').click()
Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
| Playwriter | Playwright MCP | |
|---|---|---|
| Browser | Uses your Chrome | Separate managed profile by default |
| Extensions | Your existing ones | None by default |
| Login state | Already logged in | Persistent, but a separate profile |
| Attach to your Chrome | Core design | --extension mode |
| Bot handling | Real browser (disconnect to solve) | Managed automation profile |
| Native video / raw CDP | Yes | Trace-based / not exposed |
Note: Playwriter video recording is 100x more efficient than Playwright video recording, which sends base64 images for every frame.
| Playwriter | Playwright CLI | |
|---|---|---|
| Browser | Uses your Chrome | New browser by default |
| Login state | Already logged in | Persistent profile, separate |
| Extensions | Your existing ones | None by default |
| Captchas | Disconnect extension to solve | Managed automation profile |
| Programmable JS | execute with persistent state | run-code (no cross-call state) |
| Raw CDP access | First-class | Not exposed |
| Native video | chrome.tabCapture (30–60fps) | Trace / screencast based |
| Playwriter | BrowserMCP | |
|---|---|---|
| Tools | 1 execute tool | 12+ dedicated tools |
| API | Full Playwright | Limited actions |
| Context usage | Low | High (tool schemas) |
| LLM knowledge | Already knows Playwright | Must learn tools |
| Playwriter | agent-browser | |
|---|---|---|
| Browser | Uses your Chrome | Fresh Chrome for Testing |
| API surface | 1 execute + full Playwright | 50+ CLI commands, one per action |
| Actions per turn | Real JS (loops, conditions) | batch of command strings |
| Reusable logic | Import a .js function | Re-run bash sequences |
| Skill recorder | Yes | No |
| Cloud browsers | Built-in stealth + proxy | Plugin only |
| Remote control tab | Yes (Devin, cloud bots) | No |
| Playwriter | Jetski | |
|---|---|---|
| Tools | 1 tool | 17+ tools |
| Subagent | Direct execution | Spawns for each browser task |
| Latency | Low | High (agent overhead) |
| Playwriter | Claude Extension | |
|---|---|---|
| Agent support | Any MCP client | Claude only |
| Windows WSL | Yes | No |
| Context method | A11y snapshots (5-20KB) | Screenshots (100KB+) |
| Playwright API | Full | No |
| Debugger/breakpoints | Yes | No |
| Live code editing | Yes | No |
| Network interception | Full | Limited |
| Raw CDP access | Yes | No |
--remote-debugging-port)| Playwriter | Built-in CDP | |
|---|---|---|
| Setup | Click extension icon | Relaunch Chrome with special flags |
| Your real profile | Yes | Blocked on default profile since Chrome 136 |
| Permission prompt | None | "Allow remote debugging?" dialog agents can't click |
| Autonomous agents | Fully autonomous | Blocked by dialog / throwaway profile |
| Existing session | Uses your running browser | Must relaunch Chrome (lose state) |
Chrome's
--remote-debugging-portis ignored on your default profile since Chrome 136, so you must use a throwaway--user-data-dirwith none of your logins. Connecting an external CDP client also shows an "Allow remote debugging?" dialog an agent cannot click. Playwriter uses an in-Chrome extension instead: no dialog, no flags, your real profile.
+---------------------+ +-------------------+ +-----------------+
| BROWSER | | LOCALHOST | | MCP CLIENT |
| | | | | |
| +---------------+ | | WebSocket Server | | +-----------+ |
| | Extension |<---------> :19988 | | | AI Agent | |
| +-------+-------+ | 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 |
+---------------------+ (no extension click) +-----------------+
Let a remote agent (Devin, a cloud bot, a friend's CLI agent) drive your own browser. No playwriter install needed on your machine, only the extension. The agent sees every Playwriter tab and can open new ones, like a local agent.
playwriter session new --remote <id> on its machineOpening that same link in any browser shows a live, clickable view of the tab where sharing started, so you can share with a person instead of an agent. The viewer page receives no tunnel id in its initial HTTP request because the id starts in the URL fragment. Its JavaScript then uses the id to connect to the tunnel path (/tunnel/{id}/extension), keeping the id out of DNS and TLS SNI.
YOUR MACHINE (extension only) AGENT MACHINE (any box with npx)
┌───────────────────────────┐ ┌────────────────────────────────┐
│ Chrome + Extension │ Cloudflare tunnel │ playwriter CLI + local relay │
│ Playwriter tabs ◄────────┼───◄ playwriter.dev ◄──┼────── session new --remote │
└───────────────────────────┘ /remote-control#id └────────────────────────────────┘
Remote control is not a sandbox. The agent gets the same access as a local Playwriter agent, so share the id only with a person or agent you fully trust. A short denylist blocks only whole-profile cookie APIs and cache clears, so an agent cannot wipe every login by accident. The id contains 128 bits of randomness and is never reusable after revocation.
Sharing also ends when you close or disconnect the tab where you started it.
Use case: you are logged into a website and want an agent to do work in your authenticated session without giving it your password.
Control Chrome on a remote machine over the internet using traforo tunnels:
On host:
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
From remote:
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=<secret>
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
Also works on a LAN without traforo (PLAYWRITER_HOST=192.168.1.10). Full guide with use cases (remote Mac mini, user support, multi-machine control): docs/remote-access.md
localhost:19988. Traffic leaves your machine only when you enable Remote control or configure remote access.context.newPage().The local relay sends anonymous usage stats to Strada: event names, a random install id, Playwriter version, OS, session kind, and hourly execute counts. It never sends code, URLs, page content, emails, or hostnames.
Opt out with an env var, then restart the relay:
export PLAYWRITER_TELEMETRY=0 # or DO_NOT_TRACK=1
playwriter serve restart
Connect programmatically (without CLI):
import { chromium } from 'playwright-core'
import { startPlayWriterCDPRelayServer, getCdpUrl } from 'playwriter'
const server = await startPlayWriterCDPRelayServer()
const browser = await chromium.connectOverCDP(getCdpUrl())
const page = await browser.contexts()[0].newPage()
await page.goto('https://example.com')
await page.screenshot({ path: 'screenshot.png' })
// Don't call browser.close() - it closes the user's Chrome
server.close()
Or connect to a running server:
npx -y playwriter serve --host 127.0.0.1
const browser = await chromium.connectOverCDP('http://127.0.0.1:19988')
View relay server logs to debug issues:
playwriter logfile # prints the log file path
# typically: ~/.playwriter/relay-server.log
The relay log contains extension, MCP and WebSocket server logs. A separate CDP JSONL log is also created alongside it (see playwriter logfile). Both are recreated on each server start.
Example: summarize CDP traffic counts by direction + method:
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
If Playwriter is useful to you, consider sponsoring the project.
about:blank, restart Chrome (Chrome bug in chrome.debugger API)(top 24 of 100)
53,256 followers · starred Jan 2026
6,142 followers · starred Mar 2026
2,241 followers · starred Jan 2026
153 followers · starred Jan 2026
Chrome extension & CLI to let agents control your browser. Runs Playwright snippets in a stateful sandbox. Available as CLI or MCP
See the code
Let your agents control your own Chrome, via CLI or MCP. Your logins, extensions, cookies — already there.
Other browser MCPs spawn a fresh Chrome — no logins, no extensions, instantly flagged by bot detectors, double the memory. Playwriter connects to your running browser instead. One Chrome extension, full Playwright API, everything you're already logged into.
Install Extension from Chrome Web Store
Click extension icon on a tab → turns green when connected
Install the CLI and start automating the browser:
npm i -g playwriter
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
Install the skill so your agent knows how to use Playwriter:
npx -y skills add https://playwriter.dev
playwriter browser start # starts Chrome for Testing/Chromium with bundled Playwriter extension
playwriter session new # creates stateful sandbox, outputs session id (e.g. 1)
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
playwriter -s 1 -e 'console.log(await snapshot({ page: state.page }))'
playwriter -s 1 -e 'await state.page.locator("aria-ref=e5").click()'
Tip: Always use single quotes for
-eto prevent bash from interpreting$, backticks, and\in your JS code. Use double quotes for strings inside the JS.
Each session has isolated state. Browser tabs are shared across sessions.
# Browser management
playwriter browser start # auto-finds Chrome for Testing or Chromium, with recording flags enabled
playwriter browser start /path/to/browser-binary
# Session management
playwriter session new # creates stateful sandbox, outputs id (e.g. 1)
playwriter session new --tab-group agent1 --tab-group-color blue
playwriter session update 1 --tab-group research
playwriter session list # show sessions + state keys + group
playwriter session reset <id> # fix connection issues
# Execute (always use -s)
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
playwriter -s 1 -e 'await state.page.click("button")'
playwriter -s 1 -e 'console.log(await state.page.title())'
There is no default page. Create your own tab and store it in state. To use a tab the user already opened, find it by URL:
playwriter -s 1 -e 'state.page = context.pages().findLast((p) => p.url().includes("github.com"))'
Local extension sessions use a Chrome tab group named playwriter by default. Remote-control sessions put the tabs they open into remote. Use the shortest clear single-word name with no spaces, such as docs, shop, test, or scrape.
# Park a long scrape in its own group. The user can Move group to new window
# (or another screen). New tabs from this session follow that group.
playwriter session new --tab-group scrape --tab-group-color grey
# Split concurrent agents so many open tabs stay readable
playwriter session new --tab-group agent1 --tab-group-color blue
playwriter session new --tab-group agent2 --tab-group-color pink
# Name a group the user can collapse when they don't care about it
playwriter session new --tab-group done --tab-group-color grey
# Rename or recolor later
playwriter session update 1 --tab-group research
playwriter session update 1 --tab-group-color red
# Remote tabs support the same title and color options
playwriter session new --remote <id> --tab-group support
playwriter session update 1 --tab-group review --tab-group-color cyan
--tab-group-color accepts: grey, blue, red, yellow, green, pink, purple, cyan, orange. Without it, color is derived from the name. The default local playwriter group stays green.
Node programs should use connectViaExtension() instead of posting /cli/session/new themselves:
import { connectViaExtension } from 'playwriter'
await using connection = await connectViaExtension({
tabGroup: 'email-check',
tabGroupColor: 'grey',
})
const page = await connection.browser.contexts()[0].newPage()
await page.goto('https://example.com')
// connection closes automatically when the scope ends, also on throw
tabGroupColor is typed as Chrome's tab group colors. await using calls close() at scope end: it closes the tabs this connection opened (never other sessions' tabs), disconnects CDP, and deletes the session. Without await using, call await connection.close() in finally.
Pass closeTabsOnEnd: false to keep the opened tabs after close(). A later process can reuse them:
// process A
await using a = await connectViaExtension({ tabGroup: 'job', closeTabsOnEnd: false })
await (await a.browser.contexts()[0].newPage()).goto('https://example.com')
// process B, later
await using b = await connectViaExtension({ tabGroup: 'job' })
const page = b.browser.contexts()[0].pages().findLast((p) => p.url().includes('example.com'))
Tabs that b did not open are never closed by b.close().
If the extension runs in more than one browser or profile, pass selectBrowser. It gets every connected browser and returns the one to use:
await using connection = await connectViaExtension({
selectBrowser: (browsers) => {
// [{ id, browser: 'Brave', email: 'me@work.com', activeTabs, playwriterVersion }]
return browsers.find((b) => b.email === 'me@work.com')
},
})
id is stable, so you can save it and match on it later. listBrowsers() returns the same list without connecting.
Multiline:
playwriter -s 1 -e $'
const title = await state.page.title();
console.log({ title, url: state.page.url() });
'
Variables in scope: context, state (persists between calls), cloud (local CLI sessions), require, importModule, native import(), and Node.js globals. Relative imports resolve from the session working directory. There is no default page; examples below assume state.page was set as shown above.
Persist data in state:
playwriter -e "state.users = await state.page.$$eval('.user', els => els.map(e => e.textContent))"
playwriter -e "console.log(state.users)"
Intercept network requests:
playwriter -e "state.requests = []; state.page.on('response', r => { if (r.url().includes('/api/')) state.requests.push(r.url()) })"
playwriter -e "await Promise.all([state.page.waitForResponse(r => r.url().includes('/api/')), state.page.click('button')])"
playwriter -e "console.log(state.requests)"
Set breakpoints and debug:
playwriter -e "state.cdp = await getCDPSession({ page: state.page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()"
playwriter -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))"
playwriter -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })"
Live edit page code:
playwriter -e "state.cdp = await getCDPSession({ page: state.page }); state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()"
playwriter -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })"
Screenshot with labels:
playwriter -e "await screenshotWithAccessibilityLabels({ page: state.page })"
Live stream a tab to X Live / Twitch (RTMP, runs 24/7):
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
playwriter stream start -s 1 --rtmp rtmp://va.pscp.tv:80/x/<stream-key>
playwriter stream status -s 1
playwriter stream stop -s 1
Using the CLI with the skill (step 4 above) is the recommended approach. For direct MCP server configuration, see MCP.md.
Vimium-style labels for AI agents to identify elements:
await screenshotWithAccessibilityLabels({ page: state.page })
// Returns screenshot + accessibility snapshot with aria-ref selectors
await state.page.locator('aria-ref=e5').click()
Color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
| Playwriter | Playwright MCP | |
|---|---|---|
| Browser | Uses your Chrome | Separate managed profile by default |
| Extensions | Your existing ones | None by default |
| Login state | Already logged in | Persistent, but a separate profile |
| Attach to your Chrome | Core design | --extension mode |
| Bot handling | Real browser (disconnect to solve) | Managed automation profile |
| Native video / raw CDP | Yes | Trace-based / not exposed |
Note: Playwriter video recording is 100x more efficient than Playwright video recording, which sends base64 images for every frame.
| Playwriter | Playwright CLI | |
|---|---|---|
| Browser | Uses your Chrome | New browser by default |
| Login state | Already logged in | Persistent profile, separate |
| Extensions | Your existing ones | None by default |
| Captchas | Disconnect extension to solve | Managed automation profile |
| Programmable JS | execute with persistent state | run-code (no cross-call state) |
| Raw CDP access | First-class | Not exposed |
| Native video | chrome.tabCapture (30–60fps) | Trace / screencast based |
| Playwriter | BrowserMCP | |
|---|---|---|
| Tools | 1 execute tool | 12+ dedicated tools |
| API | Full Playwright | Limited actions |
| Context usage | Low | High (tool schemas) |
| LLM knowledge | Already knows Playwright | Must learn tools |
| Playwriter | agent-browser | |
|---|---|---|
| Browser | Uses your Chrome | Fresh Chrome for Testing |
| API surface | 1 execute + full Playwright | 50+ CLI commands, one per action |
| Actions per turn | Real JS (loops, conditions) | batch of command strings |
| Reusable logic | Import a .js function | Re-run bash sequences |
| Skill recorder | Yes | No |
| Cloud browsers | Built-in stealth + proxy | Plugin only |
| Remote control tab | Yes (Devin, cloud bots) | No |
| Playwriter | Jetski | |
|---|---|---|
| Tools | 1 tool | 17+ tools |
| Subagent | Direct execution | Spawns for each browser task |
| Latency | Low | High (agent overhead) |
| Playwriter | Claude Extension | |
|---|---|---|
| Agent support | Any MCP client | Claude only |
| Windows WSL | Yes | No |
| Context method | A11y snapshots (5-20KB) | Screenshots (100KB+) |
| Playwright API | Full | No |
| Debugger/breakpoints | Yes | No |
| Live code editing | Yes | No |
| Network interception | Full | Limited |
| Raw CDP access | Yes | No |
--remote-debugging-port)| Playwriter | Built-in CDP | |
|---|---|---|
| Setup | Click extension icon | Relaunch Chrome with special flags |
| Your real profile | Yes | Blocked on default profile since Chrome 136 |
| Permission prompt | None | "Allow remote debugging?" dialog agents can't click |
| Autonomous agents | Fully autonomous | Blocked by dialog / throwaway profile |
| Existing session | Uses your running browser | Must relaunch Chrome (lose state) |
Chrome's
--remote-debugging-portis ignored on your default profile since Chrome 136, so you must use a throwaway--user-data-dirwith none of your logins. Connecting an external CDP client also shows an "Allow remote debugging?" dialog an agent cannot click. Playwriter uses an in-Chrome extension instead: no dialog, no flags, your real profile.
+---------------------+ +-------------------+ +-----------------+
| BROWSER | | LOCALHOST | | MCP CLIENT |
| | | | | |
| +---------------+ | | WebSocket Server | | +-----------+ |
| | Extension |<---------> :19988 | | | AI Agent | |
| +-------+-------+ | 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 |
+---------------------+ (no extension click) +-----------------+
Let a remote agent (Devin, a cloud bot, a friend's CLI agent) drive your own browser. No playwriter install needed on your machine, only the extension. The agent sees every Playwriter tab and can open new ones, like a local agent.
playwriter session new --remote <id> on its machineOpening that same link in any browser shows a live, clickable view of the tab where sharing started, so you can share with a person instead of an agent. The viewer page receives no tunnel id in its initial HTTP request because the id starts in the URL fragment. Its JavaScript then uses the id to connect to the tunnel path (/tunnel/{id}/extension), keeping the id out of DNS and TLS SNI.
YOUR MACHINE (extension only) AGENT MACHINE (any box with npx)
┌───────────────────────────┐ ┌────────────────────────────────┐
│ Chrome + Extension │ Cloudflare tunnel │ playwriter CLI + local relay │
│ Playwriter tabs ◄────────┼───◄ playwriter.dev ◄──┼────── session new --remote │
└───────────────────────────┘ /remote-control#id └────────────────────────────────┘
Remote control is not a sandbox. The agent gets the same access as a local Playwriter agent, so share the id only with a person or agent you fully trust. A short denylist blocks only whole-profile cookie APIs and cache clears, so an agent cannot wipe every login by accident. The id contains 128 bits of randomness and is never reusable after revocation.
Sharing also ends when you close or disconnect the tab where you started it.
Use case: you are logged into a website and want an agent to do work in your authenticated session without giving it your password.
Control Chrome on a remote machine over the internet using traforo tunnels:
On host:
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token <secret>
From remote:
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=<secret>
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'
Also works on a LAN without traforo (PLAYWRITER_HOST=192.168.1.10). Full guide with use cases (remote Mac mini, user support, multi-machine control): docs/remote-access.md
localhost:19988. Traffic leaves your machine only when you enable Remote control or configure remote access.context.newPage().The local relay sends anonymous usage stats to Strada: event names, a random install id, Playwriter version, OS, session kind, and hourly execute counts. It never sends code, URLs, page content, emails, or hostnames.
Opt out with an env var, then restart the relay:
export PLAYWRITER_TELEMETRY=0 # or DO_NOT_TRACK=1
playwriter serve restart
Connect programmatically (without CLI):
import { chromium } from 'playwright-core'
import { startPlayWriterCDPRelayServer, getCdpUrl } from 'playwriter'
const server = await startPlayWriterCDPRelayServer()
const browser = await chromium.connectOverCDP(getCdpUrl())
const page = await browser.contexts()[0].newPage()
await page.goto('https://example.com')
await page.screenshot({ path: 'screenshot.png' })
// Don't call browser.close() - it closes the user's Chrome
server.close()
Or connect to a running server:
npx -y playwriter serve --host 127.0.0.1
const browser = await chromium.connectOverCDP('http://127.0.0.1:19988')
View relay server logs to debug issues:
playwriter logfile # prints the log file path
# typically: ~/.playwriter/relay-server.log
The relay log contains extension, MCP and WebSocket server logs. A separate CDP JSONL log is also created alongside it (see playwriter logfile). Both are recreated on each server start.
Example: summarize CDP traffic counts by direction + method:
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
If Playwriter is useful to you, consider sponsoring the project.
about:blank, restart Chrome (Chrome bug in chrome.debugger API)(top 24 of 100)
53,256 followers · starred Jan 2026
6,142 followers · starred Mar 2026
2,241 followers · starred Jan 2026
153 followers · starred Jan 2026