/* * Playwriter editorial page — content only. * Components imported from website/src/components/markdown.tsx. * Styles from globals.css (editorial tokens) and editorial-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, PixelatedImage, } from "website/src/components/markdown"; import placeholderScreenshot from "../assets/placeholders/placeholder-screenshot@2x.png"; 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: "Collaboration", href: "#collaboration" }, { 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 (

You want your agent to control the browser. Your actual Chrome {" \u2014 "} with logins, extensions, and cookies already there. Not a headless instance that gets blocked by every captcha and bot detector.{" "} Star on GitHub.

Your existing Chrome session. Extensions, logins, cookies {" \u2014 "} all there.

Other browser MCPs either spawn a fresh Chrome or give agents a fixed set of tools. New Chrome means no logins, no extensions, instant bot detection, and double the memory. Fixed tools mean the agent can{"'"}t profile performance, can{"'"}t set breakpoints, can{"'"}t intercept network requests {" \u2014 "} it can only do what someone decided to expose.

Playwriter gives agents the full Playwright API through a single 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.

Four steps and your agent is browsing.

  1. Install the{" "} Chrome extension
  2. Click the extension icon on a tab {" \u2014 "} it turns green
  3. Install the CLI:
{dedent` npm i -g playwriter `}

Then install the skill {" \u2014 "} it teaches your agent how to use Playwriter: which selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.

{dedent` npx -y skills add remorses/playwriter `}

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.

Click the extension icon on a tab {" \u2014 "} it attaches via{" "} 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.

{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) └─────────────────┘ `}

The relay multiplexes sessions, so multiple agents or CLI instances can work with the same browser at the same time.

Because the agent works in your browser, you can collaborate. You see everything it does in real time. When it hits a captcha, you solve it. When a consent wall appears, you click through it. When the agent gets stuck, you disable the extension on that tab, fix things manually, re-enable it, and the agent picks up where it left off.

You{"'"}re not watching a remote screen or reading logs after the fact. You{"'"}re sharing a browser {" \u2014 "} the agent does the repetitive work, you step in when it needs a human.

Your agent needs to see the page before it can act. Accessibility snapshots return every interactive element as text, with Playwright locators attached. 5{"\u2013"}20KB instead of 100KB+ for a screenshot {" \u2014 "} cheaper, faster, and the agent can parse them without vision.

{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)" `}

Use snapshots as the primary way to read pages. Only reach for screenshots when spatial layout matters {" \u2014 "} grids, dashboards, maps.

When the agent needs to understand where things are on screen,{" "} screenshotWithAccessibilityLabels overlays{" "} Vimium-style labels on every interactive element. The agent sees the screenshot, reads the labels, and clicks 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.

Run multiple agents at once without them stepping on each other. Each session is an isolated sandbox with its own{" "} state object. Variables, pages, and listeners persist between calls. Browser tabs are shared, but state is not.

{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())" `}

Things no other browser MCP can do. Set breakpoints, step through code, inspect variables at runtime. Live-edit page scripts and CSS without reloading. Full Chrome DevTools Protocol access, not a watered-down subset.

{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 {" \u2014 "} from the CLI.

Let the agent watch network traffic to reverse-engineer APIs, scrape data behind JavaScript rendering, or debug failing requests. Captured data lives in{" "} state and persists 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)" `}

Faster than scraping the DOM. The agent captures the real API calls, inspects their schemas, and replays them with different parameters. Works for pagination, authenticated endpoints, and anything behind client-side rendering.

Have the agent record what it{"'"}s doing as MP4 video. The recording uses chrome.tabCapture and runs in the extension context, so it survives page navigation.

{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{"\u2013"}60fps. Survives navigation.

Why use this over the alternatives.

Control Chrome on a remote machine {" \u2014 "} a headless Mac mini, a cloud VM, a devcontainer. A{" "} traforo{" "} tunnel exposes the relay through Cloudflare. 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 {" \u2014 "} just set{" "} PLAYWRITER_HOST=192.168.1.10. Works for MCP too {" \u2014 "} 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.

Everything runs on your machine. The relay binds to localhost:19988 and only accepts connections from the extension. No remote server, no account, no telemetry.

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