/* * Playwriter editorial page — content only. * Components imported from website/src/components/markdown.tsx. * Styles from globals.css (editorial tokens) and liveline-prism.css. */ import type { MetaFunction } from "react-router"; import dedent from "string-dedent"; import { EditorialPage, P, A, Code, Caption, CodeBlock, Section, ComparisonTable, List, OL, Li, } from "website/src/components/markdown"; export const meta: MetaFunction = () => { const title = "Playwriter - Control your Chrome with Playwright API"; const description = "Chrome extension + CLI for browser automation. Full Playwright API on your existing browser. No new windows, no flags, no context bloat."; const image = "https://playwriter.dev/og-image.png"; return [ { title }, { name: "description", content: description }, { property: "og:title", content: title }, { property: "og:description", content: description }, { property: "og:image", content: image }, { property: "og:image:width", content: "1200" }, { property: "og:image:height", content: "630" }, { property: "og:type", content: "website" }, { property: "og:url", content: "https://playwriter.dev" }, { name: "twitter:card", content: "summary_large_image" }, { name: "twitter:title", content: title }, { name: "twitter:description", content: description }, { name: "twitter:image", content: image }, ]; }; const tocItems = [ { label: "Getting started", href: "#getting-started" }, { label: "How it works", href: "#how-it-works" }, { label: "Snapshots", href: "#snapshots" }, { label: "Visual labels", href: "#visual-labels" }, { label: "Sessions", href: "#sessions" }, { label: "Debugger & editor", href: "#debugger-and-editor" }, { label: "Network interception", href: "#network-interception" }, { label: "Screen recording", href: "#screen-recording" }, { label: "Comparison", href: "#comparison" }, { label: "Remote access", href: "#remote-access" }, { label: "Security", href: "#security" }, ]; export default function IndexPage() { return (

Playwriter lets you control your Chrome browser with the full Playwright API. A Chrome extension, a local relay, and a CLI. No new browser windows, no Chrome flags, no context bloat.{" "} Star on GitHub.

Playwriter controlling Chrome with accessibility labels overlay
Your existing Chrome session. Extensions, logins, cookies — all there.

Every browser automation MCP I tried either spawns a new Chrome instance or forces you into a limited set of predefined tools. Playwriter does neither. It connects to the browser you already have open, exposes the full Playwright API through a single{" "} execute tool, and gets out of the way. One tool. Any Playwright code. No wrappers.

Three steps. Extension, icon click, then you're automating.

  1. Install the{" "} Chrome extension
  2. Click the extension icon on a tab — it turns green
  3. Install CLI and run your first command:
{dedent` npm i -g playwriter playwriter -s 1 -e "await page.goto('https://example.com')" `}

The extension connects your browser to a local WebSocket relay on{" "} localhost:19988. The CLI sends Playwright code through the relay. No remote servers, no accounts, nothing leaves your machine.

{dedent` playwriter session new # new sandbox, outputs id (e.g. 1) playwriter -s 1 -e "await page.goto('https://example.com')" playwriter -s 1 -e "console.log(await snapshot({ page }))" playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" `} Extension icon green = connected. Gray = not attached to this tab.

The extension uses chrome.debugger to attach to tabs where you clicked the icon. It opens a WebSocket connection to a local relay server. The CLI (or MCP, or your own Playwright script) connects to the same relay. CDP commands flow through; the extension forwards them to Chrome and sends responses back.

{dedent` ┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────┐ │ 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) └─────────────────┘ `}

No Chrome restart required. No --remote-debugging-port{" "} flags. The extension handles the CDP attachment transparently, and the relay multiplexes sessions so multiple agents or CLI instances can work with the same browser simultaneously.

The core feedback loop is observe → act → observe. Accessibility snapshots are the primary way to read page state. They return the full interactive element tree as text, with Playwright locators attached to every element.

{dedent` playwriter -s 1 -e "await snapshot({ page })" # Output: # - banner: # - link "Home" [id="nav-home"] # - navigation: # - link "Docs" [data-testid="docs-link"] # - link "Blog" role=link[name="Blog"] `}

Each line ends with a locator you can pass directly to{" "} page.locator(). Subsequent calls return a diff, so you only see what changed. Use{" "} search to filter large pages.

{dedent` # Search for specific elements playwriter -s 1 -e "await snapshot({ page, search: /button|submit/i })" # Always print URL first, then snapshot — pages can redirect playwriter -s 1 -e "console.log('URL:', page.url()); await snapshot({ page }).then(console.log)" `}

Snapshots are text. They cost a fraction of what screenshots cost in tokens. Use them as your primary debugging tool. Only reach for screenshots when spatial layout matters — grids, dashboards, maps.

Accessibility tree as text. 5–20KB vs 100KB+ for screenshots.

For pages where spatial layout matters,{" "} screenshotWithAccessibilityLabels overlays{" "} Vimium-style labels on every interactive element. Take a screenshot, read the labels, click by reference.

{dedent` playwriter -s 1 -e "await screenshotWithAccessibilityLabels({ page })" # Returns screenshot + accessibility snapshot with aria-ref selectors playwriter -s 1 -e "await page.locator('aria-ref=e5').click()" `}

Labels are color-coded by element type: yellow for links, orange for buttons, coral for inputs, pink for checkboxes, peach for sliders, salmon for menus, amber for tabs. The ref system is shared with{" "} snapshot(), so you can switch between text and visual modes freely.

Vimium-style labels. Screenshot + snapshot in one call.

Each session runs in an isolated sandbox with its own{" "} state object. Variables, pages, listeners persist between calls within a session. Different sessions get different state. Browser tabs are shared.

{dedent` playwriter session new # => 1 playwriter session new # => 2 playwriter session list # shows sessions + state keys # Session 1 stores data playwriter -s 1 -e "state.users = await page.$$eval('.user', els => els.map(e => e.textContent))" # Session 2 can't see it playwriter -s 2 -e "console.log(state.users)" # undefined `}

Create your own page to avoid interference from other agents. Reuse an existing about:blank tab or create a fresh one, and store it in state.

{dedent` playwriter -s 1 -e "state.myPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.myPage.goto('https://example.com')" # All subsequent calls use state.myPage playwriter -s 1 -e "console.log(await state.myPage.title())" `}

Full Chrome DevTools Protocol access. Set breakpoints, step through code, inspect variables at runtime. Live-edit page scripts and CSS without reloading.

{dedent` # Set breakpoints and debug playwriter -s 1 -e "state.cdp = await getCDPSession({ page }); state.dbg = createDebugger({ cdp: state.cdp }); await state.dbg.enable()" playwriter -s 1 -e "state.scripts = await state.dbg.listScripts({ search: 'app' }); console.log(state.scripts.map(s => s.url))" playwriter -s 1 -e "await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })" # Live edit page code playwriter -s 1 -e "state.editor = createEditor({ cdp: state.cdp }); await state.editor.enable()" playwriter -s 1 -e "await state.editor.edit({ url: 'https://example.com/app.js', oldString: 'const DEBUG = false', newString: 'const DEBUG = true' })" `}

Edits are in-memory and persist until the page reloads. Useful for toggling debug flags, patching broken code, or testing quick fixes without touching source files. The editor also supports{" "} grep across all loaded scripts.

Breakpoints, stepping, variable inspection — from the CLI.

Intercept requests and responses to reverse-engineer APIs, scrape data, or debug network issues. Store captured data in{" "} state and analyze across calls.

{dedent` # Start intercepting playwriter -s 1 -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 -s 1 -e "await page.click('button.load-more')" playwriter -s 1 -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 -s 1 -e "const data = await page.evaluate(async (url) => { const res = await fetch(url); return res.json(); }, state.responses[0].url); console.log(data)" `}

This is faster than scrolling through DOM. Capture the real API calls, inspect their schemas, and replay them with different parameters. Works for pagination, authenticated endpoints, and anything behind JavaScript rendering.

Record the active tab as video using{" "} chrome.tabCapture. The recording runs in the extension context, so it survives page navigation. Video is saved as MP4.

{dedent` # Start recording playwriter -s 1 -e "await startRecording({ page, outputPath: './recording.mp4', frameRate: 30 })" # Navigate, interact — recording continues playwriter -s 1 -e "await page.click('a'); await page.waitForLoadState('domcontentloaded')" playwriter -s 1 -e "await page.goBack()" # Stop and save playwriter -s 1 -e "const { path, duration, size } = await stopRecording({ page }); console.log(path, duration + 'ms', size + ' bytes')" `}

Unlike 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.

Native tab capture. 30–60fps. Survives navigation.

How Playwriter compares to other browser automation approaches.

Control Chrome on any machine from anywhere over the internet. The relay runs on the host alongside Chrome. A{" "} traforo{" "} tunnel exposes it through Cloudflare, giving you a secure public URL. No VPN, no firewall rules, no port forwarding.

{dedent` # On the host machine — start relay with tunnel npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token # From anywhere — set env vars and use normally export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN= playwriter -s 1 -e "await page.goto('https://example.com')" `}

Also works on a LAN without tunnels — just set{" "} 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.

Playwriter is local by default. The relay runs on{" "} localhost:19988 and only accepts connections from the extension. There's no remote server, no account, no telemetry.

  • Local only — WebSocket server binds to localhost. Nothing leaves your machine.
  • Origin validation — only the Playwriter extension origin is accepted. Browsers cannot spoof the Origin header, so malicious websites cannot connect.
  • Explicit consent — only tabs where you clicked the extension icon are controlled. No background access.
  • Visible automation — Chrome shows an automation banner on controlled tabs.
  • ); }