# Playwriter

> Docs for Playwriter, the CLI and MCP that lets agents control your real Chrome browser.

This file contains the full content of all documentation pages. For a compact index, see [llms.txt](https://playwriter.dev/llms.txt). To download all pages as a zip, use [docs.zip](https://playwriter.dev/docs.zip).

---
title: Let Agents Control Your Real Chrome Browser
url: "https://playwriter.dev/index.md"
description: "Control your real Chrome browser with Playwright from an agent, CLI, or MCP server."
---

import { HeroSection } from '../components/hero-section.tsx'

<Above>
  <HeroSection />
</Above>

A Chrome extension and CLI that let your agents control **your actual browser** with
logins, extensions, and cookies already there. No headless instance, no bot detection, no extra memory.

<Aside>
  <Tip>
    If Playwriter is useful to you, [star it on GitHub](https://github.com/remorses/playwriter) to help others find it.
  </Tip>
</Aside>

<Image src="/screenshot@2x.png" alt="Playwriter controlling Chrome with accessibility labels overlay" width={1280} height={800} placeholder="data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAQCdASoQAAoAAsBMJbACdAEOuqTtugAA/P92B/8wqVCdUf4DTDNEjywa1TC/OQwnIwnfrkbyhXrzL+jtWuRJ1s4m/1Tz4a52qUeMcHv3ZzjCooN9xGrfPwAAAA==" />

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.

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

## Getting started

**Four steps** and your agent is browsing.

### Install the extension

1. Install the [Chrome extension](https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe)
2. Click the extension icon on a tab. It turns green when connected

### Install CLI + skill

Install the CLI, then add the skill so your agent knows the good Playwriter workflows.

```bash
npm i -g playwriter
```

Then install the **skill**. It teaches your agent how to use Playwriter: which
selectors to use, how to avoid timeouts, how to read snapshots, and all available utilities.

```bash
npx -y skills add https://playwriter.dev
```

### First commands

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.

```bash
playwriter 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()"
```

## How it works

<Aside>
  <Note>
    **Requirements:** Chrome or Chromium installed, Node.js 18+, and a Playwriter-compatible agent (OpenCode, Cursor, Claude Code, etc).
  </Note>
</Aside>

Click the extension icon on a tab. 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.

```diagram
┌─────────────────────┐      ┌──────────────────────┐      ┌─────────────────┐
│  BROWSER            │  WS  │  LOCALHOST           │  WS  │  CLIENT         │
│                     │      │                      │      │                 │
│  Extension          │<────>│  WebSocket Server    │<────>│  CLI / MCP      │
│      │              │      │  :19988              │      │      │          │
│  chrome.debugger    │      │                      │      │      v          │
│      │              │      │  /extension          │      │  execute tool   │
│      v              │      │      │               │      │      │          │
│  Tab 1 (green)      │      │      v               │      │      v          │
│  Tab 2 (green)      │      │  /cdp/:id            │      │  Playwright API │
│  Tab 3 (gray)       │      └──────────────────────┘      └─────────────────┘
└─────────────────────┘
  Tab 3 not controlled (extension not clicked)
```

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

## Why it exists

Other browser MCPs usually launch a **new Chrome**. That means no login state, no
extensions, extra memory usage, and more bot detection. Playwriter keeps the
browser human by using your real Chrome session.

* **Logged-in sites:** fresh browser MCPs start logged out. Playwriter uses your cookies.
* **Extensions:** fresh browser MCPs run without your extensions. Playwriter uses the ones already installed.
* **Captchas:** fresh browser MCPs get stuck. With Playwriter, you can solve them in the shared browser.
* **API surface:** fresh browser MCPs expose fixed tools. Playwriter exposes full Playwright.
* **Debugging:** fresh browser MCPs are limited. Playwriter can use CDP, breakpoints, and network inspection.

## Collaboration

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**. The agent does the repetitive work, you step in when it needs
a human.

## Accessibility snapshots

<Aside>
  <Note>
    Snapshots are the **primary way** agents read pages. Only use screenshots when spatial layout matters (grids, dashboards, maps).
  </Note>
</Aside>

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

```bash
playwriter -e "snapshot({ page })"

# Output:
# - banner:
#     - link "Home" [id="nav-home"]
#     - navigation:
#         - link "Docs" [data-testid="docs-link"]
#         - link "Blog" role=link[name="Blog"]
```

### Search and diff

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.

```bash
# 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)"
```

## Visual labels

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.

```bash
playwriter -e "screenshotWithAccessibilityLabels({ page })"
# Returns screenshot + accessibility snapshot with aria-ref selectors

playwriter -e "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.

## Sessions

### Isolated state

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.

```bash
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 = page.$$eval('.user', els => els.map(e => e.textContent))"

# Session 2 can't see it
playwriter -s 2 -e "console.log(state.users)"  # undefined
```

### Dedicated pages

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

```bash
playwriter -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()"
```

## Debugger and editor

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.

```bash
# 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' })"
```

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.

## Network interception

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.

```bash
# 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)"
```

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

## Screen recording

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

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

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

## Comparison

See how Playwriter compares to other browser automation tools:

* [**vs Chrome Direct CDP**](/docs/vs-chrome-cdp) — Chrome's permission dialog blocks autonomous agents. Playwriter's extension avoids it entirely.
* [**vs Playwright MCP**](/docs/vs-playwright-mcp) — Fresh browser vs your real Chrome with logins and extensions.
* [**vs Playwright CLI**](/docs/vs-playwright-cli) — Same Playwright API, but running in your existing browser with CDP access.
* [**vs BrowserMCP**](/docs/vs-browser-mcp) — One execute tool vs 12+ fixed tools with high context overhead.
* [**vs Claude Extension**](/docs/vs-claude-extension) — Any MCP client, text snapshots instead of screenshots, full CDP access.

## Remote access

### Tunnel

Control Chrome on a **remote machine**, a headless Mac mini, a cloud VM, a
devcontainer. A [traforo](https://traforo.dev) tunnel exposes the relay through Cloudflare.
**No VPN, no firewall rules, no port forwarding.**

```bash
# 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')"
```

### LAN

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.

## Security

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:** 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.

## More resources

* [GitHub repository](https://github.com/remorses/playwriter)
* [Chrome Web Store extension](https://chromewebstore.google.com/detail/playwriter/jfeammnjpkecdekppnclgkkffahnhfhe)
* [Release notes](https://github.com/remorses/playwriter/releases)


---
title: Installation
url: "https://playwriter.dev/docs/installation.md"
description: "Install the Chrome extension, CLI, and agent skill in under a minute."
---

## Install the extension

Install from the [Chrome Web Store](https://chromewebstore.google.com/detail/playwriter-mcp/jfeammnjpkecdekppnclgkkffahnhfhe), then click the extension icon on any tab. The icon turns **green** when connected.

The extension works with Chrome, Chromium, Brave, Arc, Edge, and any Chromium-based browser.

<Aside>
  <Note>
    Only tabs where you click the extension icon are controllable. Other tabs stay private.
  </Note>
</Aside>

## Install the CLI

```bash
npm i -g playwriter
```

Or use without installing:

```bash
npx playwriter@latest session new
```

**Requirements:** Node.js 18+.

## Install the skill

The **skill** teaches your AI agent how to use Playwriter: which selectors to use, how to read snapshots, how to avoid common mistakes, and all available utilities.

```bash
npx -y skills add https://playwriter.dev
```

This works with any agent that supports skills (OpenCode, Cursor, Claude Code, etc.). The skill is the recommended way to use Playwriter with agents; it gives them comprehensive instructions so they can use the full API without trial and error.

## Quick start

```bash
playwriter session new                    # creates 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()'
```

## Start Chrome for Testing

If you don't have Chrome running or want a clean instance with the extension pre-loaded:

```bash
playwriter browser start
```

This auto-finds **Chrome for Testing** or Chromium on your system, launches it with recording flags enabled, and loads the bundled Playwriter extension. You can also pass a specific browser binary:

```bash
playwriter browser start /path/to/chrome
```

## Verify it works

After installing the extension and CLI:

1. Open any website in Chrome
2. Click the Playwriter extension icon (turns green)
3. Run:

```bash
playwriter session new
playwriter -s 1 -e 'console.log(await page.title())'
```

If you see the page title printed, everything is working.


---
title: "Quick Start: Automate Chrome in 2 Minutes"
url: "https://playwriter.dev/docs/quick-start.md"
description: "Install the Chrome extension, set up the CLI, and run your first browser automation commands with Playwriter."
---

## Core workflow

Every Playwriter automation follows this pattern:

```bash
# 1. Create a session
playwriter session new          # outputs: 1

# 2. Navigate
playwriter -s 1 -e "state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.page.goto('https://example.com')"

# 3. Observe (snapshot to see interactive elements)
playwriter -s 1 -e "snapshot({ page: state.page })"

# 4. Act (use locators from the snapshot)
playwriter -s 1 -e "await state.page.locator('role=link[name=\"More information...\"]').click()"

# 5. Observe again (verify the action worked)
playwriter -s 1 -e "console.log('URL:', state.page.url()); console.log(await snapshot({ page: state.page })); console.log(await getLatestLogs({ page: state.page, sinceLastCall: true }))"
```

Always **observe before and after** every action. Never chain multiple clicks blindly.

## Prerequisites

**Chrome or Chromium** installed, **Node.js 18+**, and a terminal.

## Step 1: Install the extension

Install the [Chrome extension](https://chromewebstore.google.com/detail/playwriter/jfeammnjpkecdekppnclgkkffahnhfhe) from the Chrome Web Store, then click the extension icon on a tab. It turns **green** when connected.

## Step 2: Install the CLI

```bash
npm install -g playwriter
```

Or use without installing:

```bash
npx playwriter@latest session new
```

## Step 3: Install the skill (for agents)

The **skill** teaches AI agents how to use Playwriter: which selectors to prefer, how to read snapshots, available utilities, and best practices.

```bash
npx -y skills add https://playwriter.dev
```

## Step 4: Run your first commands

```bash
# Create a new session
playwriter session new

# Navigate to a page
playwriter -s 1 -e "state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage(); await state.page.goto('https://example.com')"

# Get an accessibility snapshot
playwriter -s 1 -e "snapshot({ page: state.page })"

# Click a link using the locator from the snapshot
playwriter -s 1 -e "await state.page.locator('role=link[name=\"More information...\"]').click()"

# Take a screenshot
playwriter -s 1 -e "await state.page.screenshot({ path: './example.png', scale: 'css' })"
```

## Using with MCP

For AI assistants (Claude, Cursor, OpenCode, etc.), configure the MCP server instead of the CLI:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"]
    }
  }
}
```

The MCP exposes a single **`execute`** tool that accepts any Playwright code. Your agent sends JavaScript and gets back the result, snapshots, and screenshots automatically.

See the full [MCP setup guide](/docs/mcp-setup) for more options.

## Alternative: headless mode

If you don't want to use your personal browser, launch a **headless Chrome** instead:

```bash
# Download Chrome for Testing (first time only)
playwriter browser install

# Create a session with headless Chrome
playwriter session new --browser headless

# Use it normally
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
```

See the [headless mode guide](/docs/headless) for details.

## Alternative: direct CDP

Connect to any Chrome instance with remote debugging enabled, no extension needed:

```bash
playwriter session new --direct
```

See the [CLI reference](/docs/cli) for all connection options.

## Next steps

* [Snapshots & diffing](/docs/snapshots) for reading page state
* [Visual labels](/docs/visual-labels) for spatial element discovery
* [Sessions](/docs/sessions) for multi-agent isolation
* [CLI reference](/docs/cli) for all commands and flags


---
title: CLI Reference
url: "https://playwriter.dev/docs/cli.md"
description: "All CLI commands, flags, and usage patterns for controlling Chrome from the terminal."
---

The Playwriter CLI sends Playwright code to your browser through a local WebSocket relay. Every command connects to the relay on `localhost:19988`, which the extension also connects to. No servers, no accounts.

## Execute code

```bash
playwriter -s <sessionId> -e "<code>"
```

The `-s` flag specifies a **session ID** (required). Get one with `playwriter session new`. The `-e` flag takes JavaScript code that runs in a sandboxed Playwright environment.

<Aside>
  <Note>
    Always use **single quotes** for `-e` to prevent bash from interpreting `$`, backticks, and `\` in your JS code.
  </Note>
</Aside>

```bash
# Navigate
playwriter -s 1 -e 'await page.goto("https://example.com")'

# Click
playwriter -s 1 -e 'await page.locator("button").click()'

# Get title
playwriter -s 1 -e 'console.log(await page.title())'

# Screenshot
playwriter -s 1 -e 'await page.screenshot({ path: "/tmp/shot.png", scale: "css" })'

# Accessibility snapshot
playwriter -s 1 -e 'console.log(await snapshot({ page }))'
```

### Multiline code

Use a **heredoc** for complex scripts:

```bash
playwriter -s 1 -e "$(cat <<'EOF'
const links = await page.$$eval('a', els => els.map(e => e.href));
console.log('Found', links.length, 'links');
const text = await page.locator('body').innerText();
const price = text.match(/\$[\d.]+/);
EOF
)"
```

Or `$'...'` syntax for simpler multiline:

```bash
playwriter -s 1 -e $'
const title = await page.title();
const url = page.url();
console.log({ title, url });
'
```

### Execute from file

For longer scripts, use `-f`:

```bash
playwriter -s 1 -f script.js
```

The file runs in the same sandbox as `-e` with all context variables available.

## Session management

Each session is an **isolated sandbox** with its own `state` object. Browser tabs are shared across sessions, but state is not.

```bash
playwriter session new              # create sandbox, outputs id (e.g. 1)
playwriter session list             # show sessions + state keys
playwriter session reset <id>       # reconnect if connection is stale
playwriter session delete <id>      # remove session and clear state
```

### Direct CDP connection

Connect to Chrome's DevTools Protocol without the extension:

```bash
playwriter session new --direct
```

This requires the user to accept a debugging approval dialog in Chrome. Enable debugging first at `chrome://inspect/#remote-debugging` or launch Chrome with `--remote-debugging-port=9222`.

**Limitations:** screen recording is unavailable in direct mode.

## Browser management

```bash
playwriter browser start              # auto-find and launch Chrome for Testing
playwriter browser start /path/to/bin # launch specific binary
playwriter browser list               # list all available browsers
```

`browser start` launches Chrome with recording flags enabled and the bundled extension pre-loaded.

## Remote connection

Connect to a relay running on another machine:

```bash
playwriter --host https://my-machine-tunnel.traforo.dev --token SECRET session new
playwriter --host https://my-machine-tunnel.traforo.dev --token SECRET -s 1 -e "..."
```

Or with environment variables:

```bash
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=SECRET
playwriter session new
playwriter -s 1 -e 'await page.goto("https://example.com")'
```

See [Remote Access](/docs/remote-access) for the full guide.

## Serve command

Start the relay server explicitly (useful for remote access and Docker):

```bash
playwriter serve --token MY_SECRET        # binds to 0.0.0.0, requires --token
playwriter serve --host localhost         # binds to 127.0.0.1, no token needed
playwriter serve --token MY_SECRET --replace  # kill existing server first
```

The relay starts automatically when you use the CLI locally. Use `serve` explicitly when you need it running persistently for remote clients.

## Skill recorder

Record a workflow you perform manually in the browser as events an agent can turn
into a reusable skill. See [Skill Recorder](/docs/skill-recorder) for the full guide:

```bash
playwriter recorder start                 # start recording user actions (runs in relay daemon)
playwriter recorder start -s 1            # attach to an existing session
playwriter recorder stop                  # stop the only active recording
playwriter recorder stop 3                # stop recording 3 when several are active
playwriter recorder events                # thin event timeline of the latest recording
playwriter recorder events 4 7            # full details of events 4 and 7
playwriter recorder status                # list active recordings and their page urls
```

## Other commands

```bash
playwriter logfile                        # print log file path
playwriter skill                          # print agent instructions
playwriter completions install            # install shell completions (zsh/bash)
```

## Global flags

| Flag                 | Description                                      |
| -------------------- | ------------------------------------------------ |
| `--host <host>`      | Remote relay host (or `PLAYWRITER_HOST` env var) |
| `--token <token>`    | Auth token (or `PLAYWRITER_TOKEN` env var)       |
| `-s, --session <id>` | Session ID (required for `-e` and `-f`)          |
| `-e, --eval <code>`  | Execute JavaScript and exit                      |
| `-f, --file <path>`  | Execute JavaScript from file                     |
| `--timeout [ms]`     | Execution timeout (default: 10000)               |

## Context variables

Code executed with `-e` or `-f` has access to:

| Variable                            | Description                                             |
| ----------------------------------- | ------------------------------------------------------- |
| `page`                              | Default page (may be shared with other agents)          |
| `context`                           | Browser context, access all pages via `context.pages()` |
| `state`                             | Object persisted between calls within your session      |
| `require`                           | Load Node.js modules (e.g. `require('node:fs')`)        |
| `snapshot`                          | Get accessibility snapshot of a page                    |
| `getCDPSession`                     | Send raw CDP commands                                   |
| `createDebugger`                    | Set breakpoints, step through code                      |
| `createEditor`                      | Live-edit page scripts and CSS                          |
| `recording`                         | Start/stop screen recording                             |
| `screenshotWithAccessibilityLabels` | Screenshot with Vimium-style labels                     |
| `getLatestLogs`                     | Get browser console logs                                |
| `getCleanHTML`                      | Get cleaned HTML from page                              |
| `getPageMarkdown`                   | Extract article content as text                         |
| `waitForPageLoad`                   | Smart load detection                                    |

See [Agent Reference](/docs/skill) for the full API documentation.


---
title: MCP Setup
url: "https://playwriter.dev/docs/mcp-setup.md"
description: "Configure Playwriter as an MCP server for AI assistants that don't support skills."
---

import McpDoc from '../../../../MCP.md'

<Note>
  The **CLI with the skill** is the recommended way to use Playwriter. It gives agents comprehensive instructions and the full Playwright API through a single `execute` command. Use the MCP setup below only if your client doesn't support skills or you specifically need the MCP protocol.
</Note>

# MCP Setup

> **Note:** CLI is the recommended way to use Playwriter. See [README.md](/README) for CLI usage.

Add to your MCP client settings:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"]
    }
  }
}
```

Or auto-configure:

```sh
npx -y @playwriter/install-mcp playwriter@latest
```

## Using the MCP

1. Enable the extension on at least one tab (click icon → turns green)
2. MCP automatically starts relay server and connects to enabled tabs
3. Use the `execute` tool to run Playwright code

The MCP exposes:

* `execute` tool - run Playwright code snippets
* `reset` tool - reconnect if connection issues occur

## Environment Variables

### `PLAYWRITER_AUTO_ENABLE`

Auto-creates a tab when Playwright connects (no manual extension click needed). **Enabled by default** in both CLI and MCP. The auto-created tab starts at `about:blank`; navigate it to any URL.

Set `PLAYWRITER_AUTO_ENABLE=false` to disable and require manually enabling the extension on a tab before connecting:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_AUTO_ENABLE": "false"
      }
    }
  }
}
```

## Direct CDP (no extension needed)

Connect directly to Chrome's DevTools Protocol without the extension. Set `PLAYWRITER_DIRECT` in your MCP config:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "1"
      }
    }
  }
}
```

Enable debugging in Chrome first: open `chrome://inspect/#remote-debugging` or launch with `--remote-debugging-port=9222`.

Chrome 136+ may show an approval dialog the first time a connection is made.

You can also pass an explicit WebSocket endpoint: `PLAYWRITER_DIRECT=ws://127.0.0.1:9222/devtools/browser/abc`.

**Limitation:** screen recording is unavailable in direct mode.

## Remote Agents (Devcontainers, VMs, SSH)

Run agents in isolated environments while controlling Chrome on your host.

**On host (where Chrome runs):**

```bash
npx -y playwriter serve --token <secret>
```

**In container/VM (where agent runs):**

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest", "--host", "host.docker.internal", "--token", "<secret>"]
    }
  }
}
```

Or with environment variables:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_HOST": "host.docker.internal",
        "PLAYWRITER_TOKEN": "<secret>"
      }
    }
  }
}
```

Use `host.docker.internal` for devcontainers, or your host's IP for VMs/SSH.


---
title: "Accessibility Snapshots, Search, and Page Diffing"
url: "https://playwriter.dev/docs/snapshots.md"
description: "Read page state as structured text instead of screenshots, search for elements, and track DOM changes with automatic diffing between calls."
---

Accessibility snapshots are the **primary way** agents read pages. They return every interactive element as text with Playwright locators attached. **5-20KB instead of 100KB+** for a screenshot; cheaper, faster, and parseable without vision.

## Basic usage

```js
// Full page snapshot
await snapshot({ page: state.page })

// Output:
// - banner:
//     - link "Home" [id="nav-home"]
//     - navigation:
//         - link "Docs" [data-testid="docs-link"]
//         - link "Blog" role=link[name="Blog"]
```

Each interactive line ends with a **Playwright locator** you can pass directly to `state.page.locator()`. If multiple elements share the same locator, a `>> nth=N` suffix is added to make it unique.

## Options

| Parameter               | Type             | Default  | Description                                    |
| ----------------------- | ---------------- | -------- | ---------------------------------------------- |
| `page`                  | Page             | required | Playwright page to snapshot                    |
| `frame`                 | Frame            | -        | Snapshot a specific iframe instead             |
| `locator`               | Locator          | -        | Scope snapshot to a subtree                    |
| `search`                | string \| RegExp | -        | Filter results (first 10 matches with context) |
| `showDiffSinceLastCall` | boolean          | `true`   | Return diff since last snapshot                |
| `interactiveOnly`       | boolean          | `false`  | Only show interactive elements                 |
| `format`                | string           | -        | Output format                                  |

## Search

Filter large pages to find specific elements:

```js
// Search by string or regex
await snapshot({ page: state.page, search: /button|submit/i })

// Search for obstacles after navigation
await snapshot({ page: state.page, search: /cookie|consent|accept|login/i })
```

Search returns the **first 10 matching lines** with surrounding context. When `search` is provided, diffing is disabled by default so the search filters the full content.

## Automatic diffing

Snapshots return **full content on first call**, then **diffs on subsequent calls**. This saves tokens by only showing what changed.

```js
// First call: returns full snapshot
await snapshot({ page: state.page })

// After an action: returns only changes (+ added, - removed)
await state.page.locator('button').click()
await snapshot({ page: state.page })
// Output:
// - button "Submit"
// + button "Submit" [disabled]
// + status "Sending..."
```

The diff is only returned when it's **shorter than the full content**. If nothing changed, you get: `"No changes since last snapshot"`.

**Control diffing explicitly:**

```js
// Always get full content (disable diff)
await snapshot({ page: state.page, showDiffSinceLastCall: false })

// Combine search + diff (both disabled by default, enable explicitly)
await snapshot({ page: state.page, search: /error/i, showDiffSinceLastCall: true })
```

## Scoping to elements

Scope a snapshot to a specific part of the page to **dramatically reduce output size**:

```js
// Full page: ~150 lines (sidebar, nav, header, footer, everything)
await snapshot({ page: state.page })

// Scoped to main: ~20 lines (just the content area)
await snapshot({ locator: state.page.locator('main') })

// Scope to a dialog, form, or section
await snapshot({ locator: state.page.locator('[role="dialog"]') })
await snapshot({ locator: state.page.locator('form#checkout') })
```

## Iframe snapshots

Snapshot iframe content using `contentFrame()`:

```js
const frame = await state.page.locator('iframe').contentFrame()
await snapshot({ frame })
```

## Using locators from snapshots

Locators from snapshots map directly to Playwright:

```js
// Snapshot shows: role=radio[name="Nope, Vanilla"]
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()

// Snapshot shows: role=link[name="SIGN IN"]
await state.page.locator('role=link[name="SIGN IN"]').click()

// Snapshot shows: [data-testid="docs-link"]
await state.page.locator('[data-testid="docs-link"]').click()
```

**Never invent selectors.** The snapshot output IS the selector. Use it directly.

## Alternative content methods

### getCleanHTML

Get **cleaned HTML** from a locator or page. Automatically removes script/style/svg/head tags, unwraps empty wrappers, and keeps semantic attributes.

```js
await getCleanHTML({ locator: state.page.locator('body') })
await getCleanHTML({ locator: state.page, search: /button/i })
await getCleanHTML({ locator: state.page, showDiffSinceLastCall: false })
await getCleanHTML({ locator: state.page, includeStyles: true })  // keep class/style attrs
```

Supports the same **search** and **diffing** behavior as `snapshot()`.

### getPageMarkdown

Extract **main article content** as plain text using Mozilla Readability (same algorithm as Firefox Reader View). Strips navigation, ads, sidebars, and clutter.

```js
const content = await getPageMarkdown({ page: state.page })
// Output:
// # Article Title
// Author: John Doe | Site: example.com | Published: 2024-01-15
// > Article excerpt or description
// The main article content as plain text...
```

Also supports `search` and `showDiffSinceLastCall`.

## When to use which

| Method                                | Best for                                | Output                                                  |
| ------------------------------------- | --------------------------------------- | ------------------------------------------------------- |
| `snapshot()`                          | Interactive elements, forms, navigation | Accessibility tree with locators                        |
| `getCleanHTML()`                      | Raw HTML structure, style debugging     | Cleaned HTML                                            |
| `getPageMarkdown()`                   | Articles, blog posts, documentation     | Plain text with title/author                            |
| `screenshotWithAccessibilityLabels()` | Visual layouts, grids, dashboards       | Image + text (see [visual labels](/docs/visual-labels)) |


---
title: Screenshot with Element Labels for AI Agents
url: "https://playwriter.dev/docs/visual-labels.md"
description: Overlay Vimium-style numbered labels on every interactive element in a screenshot so AI agents can see and click by reference.
---

When the agent needs to understand **where things are on screen**, `screenshotWithAccessibilityLabels` overlays color-coded labels on every interactive element. The agent sees the screenshot, reads the labels, and interacts by reference.

## Basic usage

```js
await screenshotWithAccessibilityLabels({ page: state.page })
// Image and accessibility snapshot are automatically included in the response
```

The function takes a screenshot, overlays numbered labels (`e1`, `e2`, `e3`...), captures the annotated image, then removes the labels. Both the **image** and an **accessibility snapshot** are returned automatically.

## Interacting with refs

Use `refToLocator` to convert a visual label to a Playwright locator:

```js
// From the screenshot, the agent sees label "e5" on a button
const locator = refToLocator({ ref: 'e5' })
await state.page.locator(locator).click()
```

Or use locators from the accompanying snapshot directly:

```js
await screenshotWithAccessibilityLabels({ page: state.page })
// Snapshot shows: role=button[name="Submit"] with ref e3
await state.page.locator('role=button[name="Submit"]').click()
```

## Color coding

Labels are **color-coded by element type** for quick visual parsing:

| Color  | Element type |
| ------ | ------------ |
| Yellow | Links        |
| Orange | Buttons      |
| Coral  | Inputs       |
| Pink   | Checkboxes   |
| Peach  | Sliders      |
| Salmon | Menus        |
| Amber  | Tabs         |

## Multiple screenshots

You can take **multiple screenshots** in a single execution. All images are included in the response:

```js
await screenshotWithAccessibilityLabels({ page: state.page })
await state.page.click('button.next')
await screenshotWithAccessibilityLabels({ page: state.page })
// Both images are returned
```

## Options

| Parameter         | Type    | Default  | Description                     |
| ----------------- | ------- | -------- | ------------------------------- |
| `page`            | Page    | required | Playwright page to screenshot   |
| `interactiveOnly` | boolean | `true`   | Only label interactive elements |

## When to use

Use `screenshotWithAccessibilityLabels` for **complex visual layouts** where spatial position matters: grids, image galleries, maps, dashboards, canvas-based UIs.

For **text-heavy pages** (forms, articles, lists), prefer `snapshot()` with search. It's faster, cheaper, and uses fewer tokens.

Both methods share the same **ref system**, so you can switch between text and visual modes freely. A ref from `snapshot()` works the same as a ref from `screenshotWithAccessibilityLabels()`.

## Resizing images

To reduce token usage, resize screenshots before reading them back into context:

```js
await state.page.screenshot({ path: '/tmp/page.png', scale: 'css' })
await resizeImageForAgent({ input: '/tmp/page.png' })
// Resized image is automatically included in the response
```

`resizeImageForAgent` accepts `width`, `height`, `maxDimension`, `quality`, and `format` options.


---
title: Multi-Agent Browser Sessions with Isolated State
url: "https://playwriter.dev/docs/sessions.md"
description: Run multiple AI agents on the same browser without interference. Each session has isolated state that persists across calls.
---

Sessions let you run **multiple agents at once** without interference. 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.

## Creating sessions

```bash
playwriter session new    # => 1
playwriter session new    # => 2

# List all sessions with their stored keys
playwriter session list
# ID  State Keys
# --------------
# 1   myPage, userData
# 2   -
```

Pass `-s <id>` to all commands to use a specific session:

```bash
playwriter -s 1 -e "state.users = ['Alice', 'Bob']"
playwriter -s 2 -e "console.log(state.users)"  # undefined (isolated)
```

## State persistence

The `state` object persists between execute calls **within the same session**. Use it to store pages, data, listeners, and anything else you need across calls:

```js
// Call 1: store data
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage()
await state.page.goto('https://example.com')
state.results = []

// Call 2: data is still there
console.log(state.results.length) // 0
state.results.push(await state.page.title())
```

## Pages are shared, state is not

`context.pages()` returns all browser tabs with Playwriter enabled, **shared across all sessions**. Multiple agents see the same tabs. To avoid interference, always **get your own page**:

```js
// First call: grab an empty tab or create one, navigate immediately
state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage()
await state.page.goto('https://example.com')
// Store in state.page and use it for ALL subsequent operations
```

Navigate **in the same call** as creating the page. This prevents another agent from grabbing the same `about:blank` tab between execute calls.

## Handle page closures

The user may close your tab. Always check before using it:

```js
if (!state.page || state.page.isClosed()) {
  state.page = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage()
}
await state.page.goto('https://example.com')
```

## Using existing pages

Only use a page from `context.pages()` if the user explicitly asks you to control a specific tab:

```js
const pages = context.pages().filter(x => x.url().includes('myapp.com'))
if (pages.length === 0) throw new Error('No myapp.com page found')
state.targetPage = pages[0]
```

## Session management

```bash
# Reset a session (reconnect browser, clear state)
playwriter session reset 1

# Delete a session
playwriter session delete 1

# List all sessions
playwriter session list
```

## Context variables

Every session has access to these globals:

| Variable                           | Description                                             |
| ---------------------------------- | ------------------------------------------------------- |
| `state`                            | Persisted object, isolated per session                  |
| `page`                             | Default page (shared, prefer `state.page`)              |
| `context`                          | Browser context, access all pages via `context.pages()` |
| `require`                          | Load Node.js modules (`path`, `fs`, `crypto`, etc.)     |
| `fetch`                            | Standard fetch API                                      |
| `Buffer`, `URL`, `URLSearchParams` | Standard globals                                        |
| `setTimeout`, `setInterval`        | Timers                                                  |
| `crypto`, `process`                | Node.js globals                                         |

## Popup handling

Popup windows (`window.open`, OAuth login flows, `target=_blank`) are **auto-relocated to tabs** in the main window by the extension. The new tab appears in `context.pages()`:

```js
await state.page.locator('button:has-text("Login with Google")').click()
await state.page.waitForTimeout(1000)

// New tab is the last page
const pages = context.pages()
const loginPage = pages[pages.length - 1]
await loginPage.locator('[data-email]').first().click()
```

You'll receive a `[WARNING] New page opened from current page (index N, initial url: ...)` message pointing to the new tab.

## Clean up

Always clean up listeners at the end of your message to prevent memory leaks:

```js
state.page.removeAllListeners()
```

Never call `browser.close()` or `context.close()`. Only close pages you created yourself.


---
title: "JavaScript Debugger, Live Code Editor, and CSS Inspector"
url: "https://playwriter.dev/docs/debugger.md"
description: "Set breakpoints, step through JavaScript, inspect variables, live-edit page scripts and CSS at runtime, and inspect React components from the CLI."
---

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

## JavaScript debugger

The `createDebugger` utility gives agents the same power as Chrome DevTools' Sources panel: set breakpoints, step through execution, inspect local variables, and evaluate expressions in scope.

### Setup

```js
state.cdp = await getCDPSession({ page: state.page })
state.dbg = createDebugger({ cdp: state.cdp })
await state.dbg.enable()
```

### List scripts

Find loaded scripts by name or URL pattern:

```js
state.scripts = await state.dbg.listScripts({ search: 'app' })
state.scripts.map(s => s.url)
// => ['https://example.com/static/app.js', 'https://example.com/static/app-utils.js']
```

### Set breakpoints

```js
// By file and line number
await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42 })

// With a condition
await state.dbg.setBreakpoint({ file: state.scripts[0].url, line: 42, condition: 'count > 10' })
```

### Step through code

When execution hits a breakpoint:

```js
// Inspect local variables in the current scope
const vars = await state.dbg.inspectLocalVariables()
console.log(vars)

// Step through execution
await state.dbg.stepOver()   // next line
await state.dbg.stepInto()   // into function call
await state.dbg.stepOut()    // out of current function

// Continue execution
await state.dbg.resume()
```

### Pause on exceptions

```js
await state.dbg.setPauseOnExceptions({ state: 'uncaught' })
// Options: 'none', 'uncaught', 'all'
```

### API reference

For the full debugger API, fetch the reference docs:

```bash
curl https://playwriter.dev/resources/debugger-api.md
```

## Live code editor

The `createEditor` utility lets agents view and **edit page scripts and CSS at runtime**. 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.

### Setup

```js
state.cdp = await getCDPSession({ page: state.page })
state.editor = createEditor({ cdp: state.cdp })
await state.editor.enable()
```

### Search across all loaded scripts

```js
const matches = await state.editor.grep({ regex: /console\.log/ })
matches.forEach(m => console.log(m.url, m.lineNumber, m.lineContent))
```

### Edit source code

```js
await state.editor.edit({
  url: 'https://example.com/app.js',
  oldString: 'const DEBUG = false',
  newString: 'const DEBUG = true'
})
```

Edits are **in-memory only**. They persist until the page reloads. The editor also supports searching across all loaded scripts with `grep`.

### API reference

For the full editor API, fetch the reference docs:

```bash
curl https://playwriter.dev/resources/editor-api.md
```

## CSS inspection

Inspect CSS styles applied to any element, like the browser DevTools "Styles" panel. Returns selector, source location (file:line), and declarations for each matching rule.

```js
const cdp = await getCDPSession({ page: state.page })
const styles = await getStylesForLocator({
  locator: state.page.locator('.btn'),
  cdp
})
console.log(formatStylesAsText(styles))
```

For the full styles API, fetch the reference docs:

```bash
curl https://playwriter.dev/resources/styles-api.md
```

## React component inspection

Inspect React component source locations and props (dev mode only):

```js
// Get source file location
const source = await getReactSource({
  locator: state.page.locator('[data-testid="submit-btn"]')
})
// => { fileName: '/src/Button.tsx', lineNumber: 42, columnNumber: 5, componentName: 'Button' }

// Get full component info with props and hierarchy
const info = await getReactComponentInfo({
  locator: state.page.locator('[data-testid="submit-btn"]')
})
// => { componentName: 'Button', source: {...}, hierarchy: [...], props: {...} }
```

`getReactComponentInfo` returns `null` for non-React elements and never throws. Props are sanitized so functions, DOM nodes, and circular references don't flood the output.

## Raw CDP access

For anything not covered by the utilities above, use `getCDPSession` to send raw Chrome DevTools Protocol commands:

```js
const cdp = await getCDPSession({ page: state.page })

// Get page metrics
const metrics = await cdp.send('Page.getLayoutMetrics')

// Get cookies
const { cookies } = await cdp.send('Network.getCookies', { urls: [state.page.url()] })

// Performance metrics
const { metrics: perfMetrics } = await cdp.send('Performance.getMetrics')
```

Always use `getCDPSession({ page })`, never `context.newCDPSession()`. The latter doesn't work through the Playwriter relay.


---
title: "Network Interception, API Capture, and Cookie Management"
url: "https://playwriter.dev/docs/network.md"
description: "Intercept HTTP requests, capture API responses for scraping, replay endpoints with different parameters, and manage cookies via CDP."
---

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.

## Capture API calls

Set up listeners to capture requests and responses matching a pattern:

```js
state.requests = []
state.responses = []

state.page.on('request', req => {
  if (req.url().includes('/api/')) {
    state.requests.push({
      url: req.url(),
      method: req.method(),
      headers: req.headers()
    })
  }
})

state.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 and analyze

After setting up listeners, trigger actions that make API calls:

```js
await state.page.click('button.load-more')
// Wait for network activity
await state.page.waitForTimeout(2000)
```

Then analyze what was captured:

```js
console.log('Captured', state.responses.length, 'API calls')
state.responses.forEach(r => console.log(r.status, r.url.slice(0, 80)))
```

## Inspect response schemas

Dig into a specific response to understand the API structure:

```js
const resp = state.responses.find(r => r.url.includes('users'))
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
```

## Replay API calls

Replay captured API calls with different parameters. Useful for pagination, authenticated endpoints, and anything behind client-side rendering:

```js
const { url, headers } = state.requests.find(r => r.url.includes('feed'))
const data = await state.page.evaluate(async ({ url, headers }) => {
  const res = await fetch(url, { headers })
  return res.json()
}, { url, headers })
console.log(data)
```

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

## Authenticated fetches

Fetch from within the page context to **include session cookies automatically**:

```js
const data = await state.page.evaluate(async url => {
  const resp = await fetch(url)
  return await resp.text()
}, 'https://example.com/protected/resource')
```

## Cookie management

Read page cookies via CDP:

```js
const cdp = await getCDPSession({ page: state.page })
const { cookies } = await cdp.send('Network.getCookies', {
  urls: [state.page.url()]
})
console.log(cookies)
```

**Clear cookies for a specific domain** (not the entire profile):

```js
const cdp = await getCDPSession({ page: state.page })
const { cookies } = await cdp.send('Network.getCookies', {
  urls: ['https://example.com', 'https://www.example.com']
})
for (const cookie of cookies) {
  await cdp.send('Network.deleteCookies', {
    name: cookie.name,
    domain: cookie.domain
  })
}
```

<Note>
  **Never use `Network.clearBrowserCookies`**. It's a profile-wide destructive operation that wipes ALL cookies across every domain. It will log the user out of Gmail, GitHub, and everything else.
</Note>

## Clean up listeners

Always clean up when done to prevent memory leaks:

```js
state.page.removeAllListeners('request')
state.page.removeAllListeners('response')
```

## Downloads

Capture and save browser downloads:

```js
const [download] = await Promise.all([
  state.page.waitForEvent('download'),
  state.page.click('button.download')
])
await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
```

For large data that would truncate in console output, trigger a browser download instead:

```js
await state.page.evaluate(async url => {
  const resp = await fetch(url)
  const data = await resp.text()
  const blob = new Blob([data], { type: 'application/octet-stream' })
  const a = document.createElement('a')
  a.href = URL.createObjectURL(blob)
  a.download = 'data.json'
  a.click()
}, 'https://example.com/protected/large-file')
```


---
title: Record Browser Automation as MP4 Video
url: "https://playwriter.dev/docs/recording.md"
description: "Record browser sessions as MP4 video that survives page navigation, with ghost cursor overlay and automatic demo acceleration for idle sections."
---

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**. Unlike `getDisplayMedia`, the extension holds the `MediaRecorder`, not the page.

## Start recording

```js
await recording.start({
  page: state.page,
  outputPath: '/absolute/path/to/recording.mp4',
  frameRate: 30,
})
```

## Navigate and interact

Recording continues across page navigations. No restart needed:

```js
await state.page.click('a')
await state.page.waitForLoadState('domcontentloaded')
await state.page.goBack()
// Still recording
```

## Stop and save

```js
state.recordingResult = await recording.stop({ page: state.page })
// => { path, duration, executionTimestamps }
```

Save the full result to `state` because `executionTimestamps` is needed for `createDemoVideo`.

## Options

| Parameter            | Type           | Default                    | Description                            |
| -------------------- | -------------- | -------------------------- | -------------------------------------- |
| `page`               | Page           | required                   | Page to record                         |
| `outputPath`         | string         | required                   | Absolute path for the output MP4       |
| `frameRate`          | number         | 30                         | Frames per second                      |
| `audio`              | boolean        | false                      | Record tab audio                       |
| `videoBitsPerSecond` | number         | 2500000                    | Video bitrate                          |
| `aspectRatio`        | object \| null | `{ width: 16, height: 9 }` | Auto-resize viewport; `null` to skip   |
| `maxDurationMs`      | number         | 15 min                     | Auto-stop safety limit; `0` to disable |

## Other recording commands

```js
// Check if recording is active
await recording.isRecording({ page: state.page })

// Cancel recording without saving
await recording.cancel({ page: state.page })
```

## Ghost cursor

The extension injects a **ghost cursor** overlay on every Playwriter-attached tab. It follows mouse actions automatically, making recordings look natural.

```js
// Change cursor style
await ghostCursor.show({ page: state.page, style: 'screenstudio' })
// Styles: 'minimal' (default), 'dot', 'screenstudio'

// Temporarily hide the cursor
await ghostCursor.hide({ page: state.page })
```

For best-looking recordings, use **locator-based interactions** (`locator.click()`, `page.mouse.move()`) instead of `goto()` to show realistic cursor motion.

## Demo video acceleration

After recording, use `createDemoVideo` to **speed up idle sections** (time between execute calls) while keeping interactions at normal speed. Requires `ffmpeg` and `ffprobe` installed.

```js
// First, stop recording and save the result
state.recordingResult = await recording.stop({ page: state.page })

// Then in a SEPARATE execute call (with --timeout 120000):
const demoPath = await createDemoVideo({
  recordingPath: state.recordingResult.path,
  durationMs: state.recordingResult.duration,
  executionTimestamps: state.recordingResult.executionTimestamps,
  speed: 6,  // default 6x for idle sections
})
```

This can take **60-120+ seconds**. Always pass `--timeout 120000` or higher.

## Limitations

* **Extension mode only.** Recording uses `chrome.tabCapture` which requires the extension. Not available in direct CDP mode or headless mode.
* **User must click the extension icon** on the tab before recording starts.
* **Auto-stops after 15 minutes** by default. Override with `maxDurationMs`.
* **Viewport auto-resizes** to 16:9 aspect ratio. Pass `aspectRatio: null` to keep the current size.

## Automatic tab capture

To enable recording without requiring the user to click the extension icon, launch Chrome with these flags:

```bash
# macOS
open -a "Google Chrome" --args \
  --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe \
  --auto-accept-this-tab-capture

# Linux
google-chrome \
  --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe \
  --auto-accept-this-tab-capture &
```


---
title: Live Stream a Browser Tab to RTMP
url: "https://playwriter.dev/docs/streaming.md"
description: "Stream a browser tab live to X Live, Twitch, or YouTube via RTMP. Uses chrome.tabCapture plus ffmpeg re-encoding, survives page navigation, and runs 24/7."
---

Stream a tab **live to RTMP endpoints** like X Live, Twitch, or YouTube. Playwriter captures the tab with the same `chrome.tabCapture` pipeline used for [recording](/docs/recording), so the **stream survives page navigation**. The relay pipes each capture chunk to an `ffmpeg` process that re-encodes in real time and pushes to your destinations.

ffmpeg runs **inside the relay process**, so the stream keeps running after the CLI exits. Perfect for 24/7 auto-generated content: render a web page, stream it forever.

## Requirements

* `ffmpeg` installed and on `PATH`
* Extension capture permission: use `playwriter browser start` for automated flows, or click the Playwriter extension icon on the tab once
* Not available in headless or direct CDP mode (relies on the extension's `chrome.tabCapture`)

## Limitations

* **Manual permission click per tab.** Chrome's `activeTab` model requires a user gesture: you must click the Playwriter extension icon on the tab before it can be captured, and the grant is **per tab** — streaming a different tab needs another click on that tab. This is a Chrome platform restriction, not a Playwriter one. For fully automated flows, launch a managed browser with `playwriter browser start`, which auto-accepts tab capture without any click.
* **One stream per tab.** A tab can have only one active capture: it can be streamed or recorded, not both, and not streamed twice.
* **Multiple tabs at once is untested.** The architecture supports one stream per tab (each with its own ffmpeg process), but Chrome does not officially document support for many simultaneous `tabCapture` streams, and each stream costs a full real-time encode. Test with your setup before relying on it for production multi-streaming; prefer the tee muxer (repeat `--rtmp`) when you want the *same* tab on several platforms — that's one capture and one encode.
* **A few seconds of latency** (capture chunking + encoder buffering) — fine for live streaming, not for real-time interaction.

## Start streaming

The stream captures the session's **current page** — navigate first, then start:

```bash
playwriter -s 1 -e "await page.goto('https://my-generated-content.example')"
playwriter stream start -s 1 --rtmp rtmp://va.pscp.tv:80/x/<stream-key>
```

Simultaneous multi-streaming — repeat `--rtmp` (uses ffmpeg's tee muxer, one encode for all destinations; a dead endpoint doesn't kill the others):

```bash
playwriter stream start -s 1 \
  --rtmp rtmp://va.pscp.tv:80/x/<x-stream-key> \
  --rtmp rtmp://live.twitch.tv/app/<twitch-stream-key>
```

## Monitor and stop

```bash
# Uptime, encoder fps, output bitrate, dropped frames
playwriter stream status -s 1

# Graceful stop: flushes the encoder and waits for ffmpeg to exit
playwriter stream stop -s 1
```

## Options

| Flag                      | Default     | Description                                                                      |
| ------------------------- | ----------- | -------------------------------------------------------------------------------- |
| `--rtmp <url>`            | required    | RTMP destination with stream key, repeatable                                     |
| `--resolution <WxH>`      | `1920x1080` | Output resolution, ffmpeg scales the capture                                     |
| `--fps <n>`               | `30`        | Output frame rate                                                                |
| `--video-bitrate <kbps>`  | `9000`      | X Live recommended; Twitch max is 6000                                           |
| `--keyframe-interval <s>` | `3`         | X Live recommended (and max); Twitch recommends 2                                |
| `--audio-bitrate <kbps>`  | `128`       | AAC 44100Hz stereo                                                               |
| `--no-audio`              | off         | Skip tab audio, injects a silent track (X Live requires an audio track)          |
| `--preset <name>`         | `veryfast`  | x264 preset, only applies to libx264                                             |
| `--codec <name>`          | auto        | Auto-detects hardware encoders (videotoolbox, nvenc, qsv), falls back to libx264 |

Defaults match **X Live's recommended encoder settings** (1080p, 9000 kbps H.264, 30 fps, 3s keyframes, AAC 128k 44100Hz stereo). For Twitch use `--video-bitrate 6000 --keyframe-interval 2`.

## Executor API

The same functionality is available inside `execute` code:

```js
await stream.start({
  rtmpUrls: ['rtmp://va.pscp.tv:80/x/STREAM_KEY'],
  resolution: '1920x1080',
  fps: 30,
  videoBitrateKbps: 9000,
})

await stream.status()
// => { streaming, startedAt, destinations, stats: { ffmpegFps, ffmpegBitrateKbps, droppedFrames, ... } }

await stream.stop()
// => { duration, bytesReceived }
```

Stream keys are secrets: status output and logs only ever show **redacted destinations** (`rtmp://host/…`).

## Latency

The extension emits capture chunks every second, and the encoder adds a little buffering: expect roughly **2 to 4 seconds** of glass-to-glass latency before the platform's own delay. Fine for live streams, not designed for real-time interaction.

## Streaming vs recording

|                  | `recording.*`                                                     | `stream.*`                        |
| ---------------- | ----------------------------------------------------------------- | --------------------------------- |
| Output           | MP4 file on disk                                                  | RTMP endpoints                    |
| Duration         | auto-stops after 15 min                                           | unlimited, runs after CLI exits   |
| Encoding         | in-browser MediaRecorder                                          | ffmpeg re-encode (CBR, fixed GOP) |
| Same tab at once | one capture per tab — a tab can be recorded or streamed, not both |                                   |


---
title: "Headless Chrome, Direct CDP, and CI Setup"
url: "https://playwriter.dev/docs/headless.md"
description: "Run Playwriter with headless Chrome for CI and servers, connect directly to any Chrome via CDP, or use cloud browser providers without an extension."
---

Launch a **headless Chrome** automatically with no extension setup and no user browser involvement. Useful when the user doesn't want their personal browser used, in CI/server environments, or for fully autonomous automation.

## Quick start

```bash
# Install Chrome for Testing (first time only)
playwriter browser install

# Launch headless Chrome and create a session
playwriter session new --browser headless

# Use the session normally
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
playwriter -s 1 -e "snapshot({ page: state.page })"
```

## How it works

`--browser headless` launches a headless Chromium process and connects to it via CDP, bypassing the extension entirely. Multiple sessions **reuse the same Chrome process**, so creating additional sessions is instant.

If no Chrome binary is found, Playwriter will tell you to run `playwriter browser install` first to download Chrome for Testing.

## Browser management

```bash
# Download Chrome for Testing
playwriter browser install

# Start Chrome manually (headed, with custom profile)
playwriter browser start --user-data-dir ./my-profile
playwriter browser start --headed

# List available browsers
playwriter browser list
```

### browser start options

| Flag                    | Description                                           |
| ----------------------- | ----------------------------------------------------- |
| `--user-data-dir <dir>` | Persistent profile directory                          |
| `--headless`            | Headless mode (default)                               |
| `--headed`              | Show browser window                                   |
| `--disable-sandbox`     | Disable Chrome sandbox (needed in some Docker setups) |

## Direct CDP connection

Connect to any Chrome instance with remote debugging enabled, including cloud browser providers:

```bash
# Auto-discover local Chrome with debugging enabled
playwriter session new --direct

# Explicit WebSocket endpoint
playwriter session new --direct ws://localhost:9222/devtools/browser/...

# Cloud browser provider
playwriter session new --direct wss://xxx.cdp.browser-use.com

# Remote host:port (auto-resolves to ws://)
playwriter session new --direct 192.168.1.50:9222
```

### Enable remote debugging in Chrome

Either:

* Open `chrome://inspect/#remote-debugging` in Chrome
* Launch Chrome with `--remote-debugging-port=9222`
* Use `playwriter browser start` (enables debugging automatically)

## MCP configuration for headless

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "1"
      }
    }
  }
}
```

`PLAYWRITER_DIRECT` accepts:

| Value                   | Behavior                              |
| ----------------------- | ------------------------------------- |
| `1`                     | Auto-discover Chrome on port 9222     |
| `ws://` or `wss://` URL | Explicit WebSocket endpoint           |
| `host:port`             | Resolves via HTTP probe to ws\:// URL |

## CI / Docker

For CI environments, combine headless mode with `--disable-sandbox`:

```bash
playwriter browser install
playwriter session new --browser headless
playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
```

Or connect to a Chrome instance running in the CI environment:

```bash
# Start Chrome with remote debugging
google-chrome --headless --remote-debugging-port=9222 --no-sandbox &

# Connect to it
playwriter session new --direct localhost:9222
```

## Limitations

| Feature              | Extension mode | Headless / Direct CDP |
| -------------------- | -------------- | --------------------- |
| Screen recording     | Yes            | No                    |
| Ghost cursor         | Yes            | No                    |
| Tab grouping         | Yes            | No                    |
| All Playwright APIs  | Yes            | Yes                   |
| Snapshots            | Yes            | Yes                   |
| Visual labels        | Yes            | Yes                   |
| Debugger & editor    | Yes            | Yes                   |
| Network interception | Yes            | Yes                   |


---
title: "CPU Profiling, Web Vitals, and React Performance Monitoring"
url: "https://playwriter.dev/docs/profiling.md"
description: "Profile JavaScript execution with V8 CPU profiler, measure Core Web Vitals (LCP, CLS, FCP), track React component render times, and analyze network transfer sizes."
---

Playwriter gives agents full access to Chrome's built-in profilers through CDP. Capture **CPU profiles**, measure **Web Vitals**, track **React component renders**, and analyze **network performance**. All from the command line or MCP, no DevTools UI needed.

## CPU profiling with CDP

Drive Chrome's V8 CPU profiler over CDP to capture `.cpuprofile` files, then analyze them with [profano](https://github.com/remorses/profano).

### Start profiling

```js
state.cdp = await getCDPSession({ page: state.page })
await state.cdp.send('Profiler.enable')
await state.cdp.send('Profiler.setSamplingInterval', { interval: 1000 }) // microseconds
await state.cdp.send('Profiler.start')
console.log('profiling started')
```

The `interval` is in **microseconds**. 1000 = 1ms sample interval (default). Lower values give finer detail but larger files.

### Interact with the page

Do whatever triggers the code path you want to profile. Only work between `Profiler.start` and `Profiler.stop` ends up in the profile:

```js
await state.page.locator('button').first().click()
await state.page.waitForTimeout(2000)
```

### Stop and save

```js
const { profile } = await state.cdp.send('Profiler.stop')
await state.cdp.send('Profiler.disable')
const fs = require('node:fs')
fs.mkdirSync('./tmp/cpu-profiles', { recursive: true })
const path = `./tmp/cpu-profiles/browser-${Date.now()}.cpuprofile`
fs.writeFileSync(path, JSON.stringify(profile))
console.log('wrote', path, '-', profile.samples.length, 'samples')
```

### Analyze with profano

```bash
npm install -g profano

# Hot leaves (default, sorted by self-time)
profano ./tmp/cpu-profiles/browser-*.cpuprofile

# Expensive callers (sorted by total/inclusive time)
profano ./tmp/cpu-profiles/browser-*.cpuprofile --sort total -n 20
```

Example output:

```
Duration: 12.34s
Samples:  11542 active / 12340 total (6.4% idle)
Sort:     self

   Self  %Self   Self ms    Total  %Total  Total ms  Function               Location
───────  ──────  ───────  ───────  ──────  ────────  ──────────────────────  ──────────────
   3402   29.5%    3.40s     6804   58.9%     6.80s  parseAsync              src/parser.ts:142
```

Start with `--sort self` to find CPU-bound leaves. Switch to `--sort total` to find expensive callers that dominate wall time.

## Web Vitals

Collect **Core Web Vitals** (TTFB, FCP, LCP, CLS) from any page using PerformanceObserver:

```js
// Install observers before navigation
await state.page.evaluate(() => {
  window.__metrics = { paints: {}, lcp: 0, cls: 0 }

  new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      window.__metrics.paints[entry.name] = entry.startTime
    }
  }).observe({ type: 'paint', buffered: true })

  new PerformanceObserver(list => {
    const entries = list.getEntries()
    const last = entries[entries.length - 1]
    if (last) window.__metrics.lcp = last.startTime
  }).observe({ type: 'largest-contentful-paint', buffered: true })

  new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      if (!entry.hadRecentInput) window.__metrics.cls += entry.value || 0
    }
  }).observe({ type: 'layout-shift', buffered: true })
})

// Reload to capture fresh metrics
await state.page.reload({ waitUntil: 'domcontentloaded' })
await state.page.waitForTimeout(3000)

// Collect results
const report = await state.page.evaluate(() => {
  const nav = performance.getEntriesByType('navigation')[0]
  return {
    ttfb: nav?.responseStart || 0,
    domContentLoaded: nav?.domContentLoadedEventEnd || 0,
    load: nav?.loadEventEnd || 0,
    fcp: window.__metrics.paints['first-contentful-paint'] || 0,
    lcp: window.__metrics.lcp || 0,
    cls: window.__metrics.cls || 0,
  }
})
console.log(report)
```

| Metric   | What it measures                               |
| -------- | ---------------------------------------------- |
| **TTFB** | Time to First Byte; server response time       |
| **FCP**  | First Contentful Paint; first visible content  |
| **LCP**  | Largest Contentful Paint; main content visible |
| **CLS**  | Cumulative Layout Shift; visual stability      |

## Long tasks and interaction latency

Detect **long tasks** (>50ms) and slow **event handlers** that block interactivity:

```js
await state.page.evaluate(() => {
  window.__longTasks = []
  window.__eventTimings = []

  new PerformanceObserver(list => {
    window.__longTasks.push(...list.getEntries().map(e => ({
      startTime: e.startTime,
      duration: e.duration
    })))
  }).observe({ type: 'longtask', buffered: true })

  new PerformanceObserver(list => {
    window.__eventTimings.push(...list.getEntries().map(e => ({
      name: e.name,
      duration: e.duration,
      interactionId: e.interactionId || 0
    })))
  }).observe({ type: 'event', buffered: true, durationThreshold: 16 })
})

// Interact with the page
await state.page.locator('button').first().click()

// Collect results
const report = await state.page.evaluate(() => ({
  longTasks: window.__longTasks.filter(e => e.duration >= 50),
  events: window.__eventTimings.filter(e => e.interactionId !== 0),
}))
console.log(report)
```

## Network analysis

Measure the **heaviest transferred resources** using raw CDP network events:

```js
const cdp = await getCDPSession({ page: state.page })
await cdp.send('Network.enable')
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true })

const responses = new Map()
const finished = new Map()

cdp.on('Network.responseReceived', event => {
  responses.set(event.requestId, {
    url: event.response.url,
    mimeType: event.response.mimeType,
  })
})

cdp.on('Network.loadingFinished', event => {
  finished.set(event.requestId, event.encodedDataLength)
})

await state.page.reload({ waitUntil: 'domcontentloaded' })
await state.page.waitForTimeout(2000)

const largest = [...responses.entries()]
  .map(([id, r]) => ({ url: r.url, mimeType: r.mimeType, bytes: finished.get(id) || 0 }))
  .sort((a, b) => b.bytes - a.bytes)
  .slice(0, 10)

console.log(largest)
```

## React component profiling

Track **React component renders** and scheduler events using React 19.2+ Performance Track entries. Requires a **development or profiling build** of React.

### Install the observer

```js
await state.page.evaluate(() => {
  window.__reactMeasures = []
  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      if (!entry.detail?.devtools?.track) continue
      window.__reactMeasures.push({
        name: entry.name,
        duration: entry.duration,
        startTime: entry.startTime,
        track: entry.detail.devtools.track,
      })
    }
  })
  observer.observe({ type: 'measure', buffered: true })
})
console.log('Observer installed')
```

React sets `detail.devtools.track` on every measure it emits. The filter keeps only React data and excludes unrelated measures from other libraries.

### Interact with the app

Click around, navigate, toggle themes, type. Any React state change triggers component renders that get captured.

### Save as .cpuprofile

Convert the captured measures to a `.cpuprofile` file that profano can analyze:

```js
const measures = await state.page.evaluate(() => window.__reactMeasures)
if (!measures.length) { console.log('No React measures captured'); return }

const TICK = 100
const nodes = [
  { id: 1, callFrame: { functionName: '(root)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [2] },
  { id: 2, callFrame: { functionName: '(idle)', scriptId: '0', url: '', lineNumber: -1, columnNumber: -1 }, children: [] },
]

const nameToId = new Map()
let nextId = 3
for (const m of measures) {
  const name = m.name.replace('\u200b', '')
  const key = m.track + '::' + name
  if (!nameToId.has(key)) {
    const id = nextId++
    nameToId.set(key, id)
    nodes.push({ id, callFrame: { functionName: name, scriptId: String(id), url: m.track, lineNumber: -1, columnNumber: -1 }, children: [] })
    nodes[0].children.push(id)
  }
}

const sorted = [...measures].sort((a, b) => a.startTime - b.startTime)
const t0 = sorted[0].startTime
const endUs = Math.round((Math.max(...sorted.map(m => m.startTime + m.duration)) - t0) * 1000)

const events = sorted.map(m => ({
  startUs: Math.round((m.startTime - t0) * 1000),
  endUs: Math.round((m.startTime + m.duration - t0) * 1000),
  nodeId: nameToId.get(m.track + '::' + m.name.replace('\u200b', '')),
}))

const samples = []
const timeDeltas = []
for (let t = 0; t < endUs; t += TICK) {
  let node = 2
  for (const ev of events) {
    if (t >= ev.startUs && t < ev.endUs) node = ev.nodeId
  }
  samples.push(node)
  timeDeltas.push(TICK)
}

const fs = require('node:fs')
fs.writeFileSync('./react-profile.cpuprofile', JSON.stringify({ nodes, samples, startTime: 0, endTime: endUs, timeDeltas }))
console.log('Saved react-profile.cpuprofile')
```

### Analyze

```bash
profano react-profile.cpuprofile --sort self
```

Example output:

```
Duration: 47.23s
Samples:  786 active / 472317 total (99.8% idle)
Sort:     self

   Self  %Self   Self ms    Total  %Total  Total ms  Function               Location
───────  ──────  ───────  ───────  ──────  ────────  ──────────────────────  ──────────
    258   32.8%   25.8ms      258   32.8%    25.8ms  Mount                   Components
     87   11.1%    8.7ms       87   11.1%     8.7ms  EditorialPage           Components
     73    9.3%    7.3ms       73    9.3%     7.3ms  Update Blocked          Transition
     62    7.9%    6.2ms       62    7.9%     6.2ms  Cascading Update        Blocking
```

The **Location** column shows the React track: `Components` for component renders, `Transition`/`Blocking`/`Idle` for scheduler events. Scheduler events like `Cascading Update` are common performance smells.

### Gotchas

* **Development builds only.** Production React builds don't emit performance measures. Use a profiling build (`react-dom/profiling`) or development mode.
* **React 19.2+ required.** Earlier versions don't emit `PerformanceObserver` measures with devtools metadata.
* **Extension overhead.** Browser extensions (React DevTools, ad blockers) show up in CPU profiles. Profile in an incognito window with extensions disabled for clean results.
* **Use `getCDPSession({ page })`** not `context.newCDPSession()`. Only the Playwriter helper works through the relay.


---
title: Security Model and Sandboxed Execution
url: "https://playwriter.dev/docs/security.md"
description: "How Playwriter keeps your browser safe with localhost-only architecture, origin validation, explicit tab consent, and a sandboxed filesystem for code execution."
---

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.

## Architecture

```diagram
┌──────────────────────────────────────────────────────────────────────────────────────────┐
│                                YOUR MACHINE                                              │
│                                                                                          │
│  ┌─────────────┐          ┌──────────────────┐          ┌──────────────┐                 │
│  │  Extension  │ <────────>  Relay Server     │<────────>  CLI / MCP   │                 │
│  │  (Chrome)   │    WS     │  localhost:19988  │   WS     │  (Agent)    │                │
│  └─────────────┘          └──────────────────┘          └──────────────┘                 │
│                                                                                          │
│  Nothing leaves localhost unless you explicitly set up remote access                     │
└──────────────────────────────────────────────────────────────────────────────────────────┘
```

## Security guarantees

**Local only.** The WebSocket server binds to localhost. Nothing leaves your machine unless you explicitly configure [remote access](/docs/remote-access) with a tunnel and auth token.

**Origin validation.** The extension WebSocket endpoint only accepts the Playwriter extension origin. Browsers cannot spoof the `Origin` header, so malicious websites cannot connect and control your browser. CLI and MCP connect as local Node.js clients on localhost; for remote access, always use [token authentication](/docs/remote-access).

**Explicit consent.** Only tabs where you clicked the extension icon are controlled. No background access to other tabs. Chrome shows an automation banner on controlled tabs so you always know which tabs are being automated.

**Visible automation.** The Chrome debugger banner is always visible on controlled tabs. You can see everything the agent does in real time.

## Sandboxed filesystem

The `require('node:fs')` module in the execution sandbox is **scoped**. Write operations only succeed in:

| Allowed path  | Description                                            |
| ------------- | ------------------------------------------------------ |
| Session cwd   | The directory where `playwriter` CLI was invoked       |
| `/tmp`        | System temp directory                                  |
| `os.tmpdir()` | OS-specific temp (e.g. `/var/folders/.../T/` on macOS) |

Writing to any other path throws `EPERM: operation not permitted, access outside allowed directories`. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.

## Sandbox restrictions

The execution sandbox runs in a controlled environment:

* **No `import` statements.** Use `require()` for Node.js modules.
* **No `__dirname` or `__filename`.** Use `process.cwd()` or absolute paths.
* **Scoped `require`.** Only safe Node.js built-in modules are available: `path`, `url`, `querystring`, `crypto`, `buffer`, `util`, `assert`, `events`, `timers`, `stream`, `zlib`, `http`, `https`, `os`, and scoped `fs`.
* **No `process.chdir()`.** Use a new session with a different cwd.
* **No `browser.close()` or `context.close()`.** These would disconnect all agents.

## Remote access security

When using [remote access](/docs/remote-access) via Traforo tunnels:

* **Token authentication** is required. The `--token` flag on `playwriter serve` enforces auth on all connections.
* **Encrypted transport.** Traforo tunnels use Cloudflare's TLS infrastructure.
* **No port forwarding.** No firewall rules or VPN needed; the tunnel handles everything.

```bash
# Host machine: serve with auth token
npx traforo -p 19988 -- playwriter serve --token MY_SECRET

# Remote machine: connect with token
export PLAYWRITER_HOST=https://my-tunnel.traforo.dev
export PLAYWRITER_TOKEN=MY_SECRET
playwriter session new
```

## Recorder start and stop

The toolbar **Record Skill** button does not fetch the relay from the page. The page posts a message to the content script. The **service worker** then calls `POST /recorder/start`.

CORS on those routes allows only the Playwriter **extension origin**. A website cannot pass the preflight, so it cannot start or stop a recording.

The worker fetch looks cross-site (`chrome-extension://` to `127.0.0.1`). `/recorder/start` and `/recorder/stop` skip the `Sec-Fetch-Site` block for that reason. `/recorder/events` and `/recorder/status` do not.

## Cookie safety

**Never use `Network.clearBrowserCookies`** via CDP. It's a profile-wide destructive operation that wipes ALL cookies across every domain in the user's Chrome profile; Gmail, GitHub, and every authenticated session.

Use scoped cookie operations instead:

```js
const cdp = await getCDPSession({ page: state.page })
const { cookies } = await cdp.send('Network.getCookies', {
  urls: ['https://example.com']
})
// Delete individually
for (const cookie of cookies) {
  await cdp.send('Network.deleteCookies', {
    name: cookie.name,
    domain: cookie.domain
  })
}
```


---
title: Remote Access
url: "https://playwriter.dev/docs/remote-access.md"
description: Control a Chrome browser on any machine from anywhere over the internet using playwriter serve and traforo tunnels.
---

Control a Chrome browser on any machine from anywhere over the internet. No VPN, no firewall rules, no port forwarding.

## How it works

Playwriter's relay server runs on the host machine alongside Chrome. A [traforo](https://traforo.dev) tunnel exposes it to the internet through Cloudflare, giving you a **secure public URL**. The remote machine connects through this URL to control Chrome.

```diagram
┌────────────────────────────────────────────────────────────────────────────────────────┐
│  HOST MACHINE (has Chrome)                                                             │
│                                                                                        │
│  Chrome + Extension <────── local WS ──────> Relay Server :19988                       │
│                                                    ^                                   │
│                                                    │ local                             │
│                                                    v                                   │
│                                               Traforo Client                           │
│                                                    │                                   │
└────────────────────────────────────────────────────┼───────────────────────────────────┘
                                                     │ outbound WS
                                                     v
                                            ┌─────────────────┐
                                            │   Cloudflare    │
                                            │   Durable Object│
                                            │                 │
                                            │   https://{id}- │
                                            │   tunnel.       │
                                            │   traforo.dev   │
                                            └────────┬────────┘
                                                     │
                                                     v
┌────────────────────────────────────────────────────────────────────────────────────────┐
│  REMOTE MACHINE (CLI or MCP)                                                           │
│                                                                                        │
│  playwriter -s 1 -e "await page.goto('https://...')"                                   │
│                                                                                        │
│  PLAYWRITER_HOST=https://{id}-tunnel.traforo.dev                                       │
│  PLAYWRITER_TOKEN=&lt;secret&gt;                                                       │
└────────────────────────────────────────────────────────────────────────────────────────┘
```

Traforo proxies both HTTP and WebSocket connections, which is critical because playwriter uses WebSockets for real-time CDP communication.

## Host machine setup

The host machine runs Chrome with the playwriter extension installed.

1. Install [Playwriter from the Chrome Web Store](https://chromewebstore.google.com/detail/playwriter/jfeammnjpkecdekppnclgkkffahnhfhe)
2. Click the extension icon on any tab you want to make controllable
3. Start the relay server with a tunnel:

```bash
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN
```

This starts `playwriter serve` on port 19988 with token auth, and creates a traforo tunnel at `https://my-machine-tunnel.traforo.dev`. Keep this terminal running, or use tmux for persistent operation:

```bash
tmux new-session -d -s playwriter-remote
tmux send-keys -t playwriter-remote \
  "npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN" Enter
```

<Aside>
  <Note>
    The **`-t` flag** sets the tunnel ID, which becomes the URL subdomain. If omitted, a random UUID is generated. Tunnel IDs are not reserved; if someone else connects with the same ID, they replace your connection.
  </Note>
</Aside>

## Remote machine setup

Set the two environment variables and use playwriter normally:

```bash
export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
```

The **CLI with the skill** is the recommended approach. The skill file (`playwriter skill`) documents all available APIs. Use playwriter exactly as you would locally:

```bash
playwriter session new          # outputs: 1
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
```

Alternatively, pass host and token as **flags** instead of env vars:

```bash
playwriter --host https://my-machine-tunnel.traforo.dev --token MY_SECRET_TOKEN -s 1 -e "..."
```

### MCP configuration

If you prefer using the MCP server over the CLI (e.g. for AI assistants that don't support the skill), set the env vars in your MCP client config:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_HOST": "https://my-machine-tunnel.traforo.dev",
        "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN"
      }
    }
  }
}
```

The env vars tell the MCP to skip starting a local relay and connect to the remote one instead.

### Playwright API (programmatic)

```typescript
import { chromium } from 'playwright-core'

const browser = await chromium.connectOverCDP(
  'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN',
)
const page = browser.contexts()[0].pages()[0]
await page.goto('https://example.com')
// Don't call browser.close() - it would close the user's Chrome
```

## Use cases

**Control a remote Mac mini.** Run Chrome on a headless machine and control it from your laptop. The Mac mini runs the tunnel persistently via tmux. Automate browser tasks, run tests against real Chrome, or manage web apps from anywhere.

**Fix issues for a user remotely.** The user starts the tunnel, shares the URL + token with you, and you can see exactly what they see: navigate their tabs, inspect elements, take screenshots. The user sees Chrome's automation banner so they always know when their browser is being controlled, and can revoke access instantly by closing the terminal.

**Control many machines at once.** Each machine runs its own tunnel with a unique `-t` ID and the same token. From a control machine, loop over the tunnel URLs to run commands across the fleet:

```bash
for machine in machine-a machine-b machine-c; do
  PLAYWRITER_HOST="https://${machine}-tunnel.traforo.dev" \
  PLAYWRITER_TOKEN=shared-secret \
  playwriter -s 1 -e "console.log(await page.title())"
done
```

**Development from a VM or devcontainer.** Your code runs in a VM or devcontainer but Chrome runs on the host. The tunnel bridges the gap without needing host networking or port forwarding. See the Docker section below.

## Docker / devcontainer setup

The relay server **must run on the same machine as Chrome**. The Chrome extension connects to the relay via localhost WebSocket, and the `/extension` endpoint only accepts connections from `127.0.0.1`. This means `playwriter serve` always runs on the host, never inside the container.

From Docker, set `PLAYWRITER_HOST` and `PLAYWRITER_TOKEN` to reach the host relay.

```diagram
┌────────────────────────────────────────────────────────────────────────────────────────┐
│  HOST MACHINE                                                                          │
│                                                                                        │
│  Chrome + Extension <────── local WS ──────> playwriter serve :19988                   │
└────────────────────────────────────────────────────────^───────────────────────────────┘
                                                         │
                                            host.docker.internal:19988
                                                         │
┌────────────────────────────────────────────────────────┴───────────────────────────────┐
│  DOCKER CONTAINER                                                                      │
│                                                                                        │
│  PLAYWRITER_HOST=host.docker.internal + PLAYWRITER_TOKEN                               │
│                                                                                        │
│  playwriter -s 1 -e "await page.goto('https://...')"                                   │
└────────────────────────────────────────────────────────────────────────────────────────┘
```

**Step 1: Host.** Start the relay server on the host machine (where Chrome is running):

```bash
playwriter serve --host 0.0.0.0 --token MY_SECRET_TOKEN
```

Docker reaches the host through a non-loopback interface, so the relay must bind to `0.0.0.0`. A token is required because this exposes the relay to the host network.

**Step 2: Docker.** Pass the host and the same token into your container:

```bash
docker run \
  -e PLAYWRITER_HOST=host.docker.internal \
  -e PLAYWRITER_TOKEN=MY_SECRET_TOKEN \
  myimage
```

Then use playwriter normally inside the container:

```bash
playwriter session new
playwriter -s 1 -e "await page.goto('https://example.com')"
```

### Platform support for `host.docker.internal`

| Platform                     | Works out of the box? | Notes                                              |
| ---------------------------- | --------------------- | -------------------------------------------------- |
| **macOS** (Docker Desktop)   | Yes                   | Supported since Docker Desktop 18.03               |
| **Windows** (Docker Desktop) | Yes                   | Supported since Docker Desktop 18.03               |
| **Linux** (Docker Engine)    | No                    | Requires `--add-host` or `extra_hosts` (see below) |

On Linux, `host.docker.internal` is **not provided automatically** by Docker Engine. You must add it explicitly:

```bash
docker run \
  --add-host=host.docker.internal:host-gateway \
  -e PLAYWRITER_HOST=host.docker.internal \
  -e PLAYWRITER_TOKEN=MY_SECRET_TOKEN \
  myimage
```

Or in Docker Compose:

```yaml
services:
  app:
    build: .
    environment:
      - PLAYWRITER_HOST=host.docker.internal
      - PLAYWRITER_TOKEN=MY_SECRET_TOKEN
    extra_hosts:
      - "host.docker.internal:host-gateway"
```

The `host-gateway` special value (available since Docker Engine 20.10) resolves to the host's gateway IP.

<Aside>
  <Warning>
    **Common mistake:** running `playwriter serve` inside the container. This won't work because the Chrome extension can only connect to the relay via localhost, and localhost inside Docker is isolated from the host. The relay must be on the same machine as Chrome.
  </Warning>
</Aside>

### MCP from Docker

If your AI assistant or MCP client runs inside Docker:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_HOST": "host.docker.internal",
        "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN"
      }
    }
  }
}
```

On Linux, make sure the container has `--add-host=host.docker.internal:host-gateway`.

## Security

**Traforo URLs are non-guessable.** Each tunnel gets a unique ID (random UUID by default). Nobody can discover your tunnel by scanning.

**Token authentication is required.** When `playwriter serve` binds to `0.0.0.0`, it refuses to start without a `--token`. Every privileged HTTP request (`/cli/*`, `/recording/*`) needs `Authorization: Bearer <token>` or `?token=<token>`, and every `/cdp` WebSocket connection needs `?token=<token>`. Without the correct token, the relay returns 401.

**Extension endpoint is localhost-only.** The `/extension` WebSocket endpoint only accepts connections from `127.0.0.1` or `::1`. A remote attacker cannot impersonate the extension even with the token.

**No open ports.** Traforo uses an outbound WebSocket to Cloudflare. The host machine needs no inbound ports open. Works behind NATs, firewalls, and corporate networks.

**Visible automation.** Chrome shows an automation banner on controlled tabs.

**Instant revocation.** Closing the terminal immediately disconnects the tunnel.

### Environment variables

| Variable           | Description                                                                        |
| ------------------ | ---------------------------------------------------------------------------------- |
| `PLAYWRITER_HOST`  | Remote relay URL (e.g. `https://x-tunnel.traforo.dev`) or IP (e.g. `192.168.1.10`) |
| `PLAYWRITER_TOKEN` | Authentication token for the relay server                                          |
| `PLAYWRITER_PORT`  | Override relay port (default: `19988`, not needed with traforo)                    |

### Recommendations

* Generate a strong random token: `openssl rand -hex 16`
* Omit `-t` in traforo to get a random tunnel ID for maximum security
* Don't share tunnel URLs in public channels
* Kill the tunnel when you're done

## Without traforo (LAN only)

If both machines are on the same network, skip traforo and connect directly:

```bash
# Host
npx -y playwriter serve --token MY_SECRET_TOKEN

# Remote (same LAN)
export PLAYWRITER_HOST=192.168.1.10
export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
playwriter session new
```


---
title: Use Cases
url: "https://playwriter.dev/docs/use-cases.md"
description: "Practical examples of what you can do with Playwriter, from QA testing to task automation."
---

Playwriter gives agents and scripts access to your **real Chrome browser** with all your logins, extensions, and cookies. Here are some things people use it for.

## Web development

Let your coding agent **verify its own work** in a real browser. The agent writes code, then uses Playwriter to navigate to the dev server, check the rendered page, take screenshots, and fix issues, all in one loop.

```bash
# Agent writes React component, then verifies
playwriter -s 1 -e 'await page.goto("http://localhost:3000/new-feature")'
playwriter -s 1 -e 'console.log(await snapshot({ page }))'
# Agent sees broken layout, fixes the code, checks again
```

<Aside>
  <Note>
    The agent works in **your browser**, so it sees the same thing you do. No headless rendering differences.
  </Note>
</Aside>

Because Playwriter exposes the **full Playwright API**, agents can also set breakpoints with `createDebugger`, inspect CSS with `getStylesForLocator`, live-edit source with `createEditor`, and intercept network requests. All from the same session.

## QA testing

Run tests against your **real Chrome profile** with real login state. No need to set up test accounts or mock authentication.

```bash
# Test authenticated flows without login setup
playwriter -s 1 -e 'await page.goto("https://myapp.com/dashboard")'
playwriter -s 1 -e 'console.log(await snapshot({ page, search: /error|warning/i }))'

# Verify forms, buttons, navigation
playwriter -s 1 -e 'await page.locator("button:has-text(\"Save\")").click()'
playwriter -s 1 -e 'console.log(await snapshot({ page, search: /success|saved/i }))'
```

Great for manual QA workflows that agents can now automate: fill forms, click through flows, verify results, and report back.

## Task automation

Automate repetitive browser tasks that need your logged-in session.

**Download a YouTube playlist:**

```bash
playwriter -s 1 -e $'
state.page = context.pages().find(p => p.url() === "about:blank") ?? await context.newPage()
await state.page.goto("https://www.youtube.com/playlist?list=PLxxxxxx")
await waitForPageLoad({ page: state.page, timeout: 5000 })
const videos = await state.page.$$eval("a#video-title", els => els.map(e => ({ title: e.textContent.trim(), href: e.href })))
console.log(JSON.stringify(videos, null, 2))
'
```

**Bulk fill forms:**

```bash
# Read CSV data and fill a form for each row
playwriter -s 1 -f fill-forms.js
```

**Export data from authenticated dashboards:**

```bash
playwriter -s 1 -e $'
state.page = context.pages().find(p => p.url() === "about:blank") ?? await context.newPage()
await state.page.goto("https://analytics.example.com/export")
const [download] = await Promise.all([
  state.page.waitForEvent("download"),
  state.page.click("button:has-text(\"Export CSV\")")
])
await download.saveAs("/tmp/analytics.csv")
console.log("Saved to /tmp/analytics.csv")
'
```

## API reverse engineering

Use **network interception** to discover undocumented APIs behind JavaScript-rendered pages.

```bash
# Start intercepting API calls
playwriter -s 1 -e $'
state.page = context.pages().find(p => p.url() === "about:blank") ?? await context.newPage()
state.responses = []
state.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 {}
  }
})
'

# Navigate and interact — API calls are captured
playwriter -s 1 -e 'await state.page.click("button.load-more")'

# Inspect captured API schemas
playwriter -s 1 -e 'state.responses.forEach(r => console.log(r.status, r.url.slice(0, 100)))'
playwriter -s 1 -e 'console.log(JSON.stringify(state.responses[0].body, null, 2).slice(0, 2000))'
```

This is much faster than scraping the DOM. The agent captures the real API calls, inspects their schemas, and can replay them with different parameters.

## Remote support

Control a user's browser to **fix issues remotely**. The user starts a tunnel, shares the URL, and you can see exactly what they see.

```bash
# User runs on their machine
npx -y traforo -p 19988 -- npx -y playwriter serve --token SHARED_SECRET

# You connect from anywhere
export PLAYWRITER_HOST=https://user-machine-tunnel.traforo.dev
export PLAYWRITER_TOKEN=SHARED_SECRET
playwriter session new
playwriter -s 1 -e 'console.log(await snapshot({ page }))'
```

The user sees Chrome's automation banner so they always know when their browser is being controlled. They can revoke access by closing the terminal. See [Remote Access](/docs/remote-access) for the full guide.

## Screen recording

Have the agent **record what it's doing** as an MP4 video. The recording uses `chrome.tabCapture` and survives page navigation.

```bash
# Start recording
playwriter -s 1 -e 'await recording.start({ page, outputPath: "/tmp/demo.mp4" })'

# Do things — recording continues through navigation
playwriter -s 1 -e 'await page.goto("https://example.com")'
playwriter -s 1 -e 'await page.click("a")'

# Stop and save
playwriter -s 1 -e 'await recording.stop({ page })'
```

Useful for creating **demo videos**, recording bug reproductions, or documenting workflows. The agent can also speed up idle sections with `createDemoVideo` to produce polished demos.

## Scraping JS-heavy sites

Sites like Instagram, Twitter, and Facebook return empty HTML shells to `curl` and `fetch`. Playwriter renders them in a **real browser** with your logged-in session.

```bash
playwriter -s 1 -e $'
state.page = context.pages().find(p => p.url() === "about:blank") ?? await context.newPage()
await state.page.goto("https://www.instagram.com/p/ABC123/", { waitUntil: "domcontentloaded" })
await waitForPageLoad({ page: state.page, timeout: 8000 })
const content = await getPageMarkdown({ page: state.page, showDiffSinceLastCall: false })
console.log(content)
'
```

The agent can dismiss cookie modals, scroll through lazy-loaded content, click through carousels, and extract data from the fully rendered page.


---
title: Troubleshooting
url: "https://playwriter.dev/docs/troubleshooting.md"
description: "Debug connection issues, read relay logs, and fix common problems."
---

## Relay server logs

The relay server logs everything: extension connections, CDP events, MCP messages, and errors. Two log files are created each time the server starts:

```bash
playwriter logfile
# prints paths like:
#   ~/.playwriter/relay-server.log
#   ~/.playwriter/cdp.jsonl
```

**`relay-server.log`** contains human-readable logs from the extension, MCP, and WebSocket server.

**`cdp.jsonl`** is a JSON Lines file with every CDP command, response, and event. Long strings are truncated. Use `jq` to analyze traffic:

```bash
# Count CDP messages by direction and method
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c

# Find errors
grep -i error ~/.playwriter/relay-server.log | tail -20
```

Both files are **recreated** every time the server starts, so they only contain logs from the current session.

## Known issues

### All pages return `about:blank`

This is a Chrome bug in the `chrome.debugger` API. **Fix:** restart Chrome completely (quit and reopen), then click the extension icon again.

### Browser switches to light mode on connect

Playwright triggers a theme change when connecting via CDP. This is an [upstream Playwright issue](https://github.com/microsoft/playwright/issues/37627). The browser returns to its normal theme when the session disconnects.

## Common problems

### "Extension is not connected"

The extension isn't connected to the relay. Check:

1. Is Chrome running?
2. Did you click the extension icon on at least one tab? (Icon should be green)
3. Is the relay server running? The CLI starts it automatically, but if you killed it manually, run `playwriter serve` or just use any CLI command to restart it

### "No browser tabs have Playwriter enabled"

You need to click the extension icon on a tab to make it controllable. The extension only attaches to tabs where you explicitly enable it.

### Connection is stale or broken

Reset the session to reconnect:

```bash
playwriter session reset <sessionId>
```

If that doesn't help, restart the relay by killing the process on port 19988 and running any CLI command:

```bash
# macOS/Linux
PIDS=$(lsof -ti :19988)
[ -n "$PIDS" ] && kill $PIDS

# Windows (PowerShell)
Get-NetTCPConnection -LocalPort 19988 -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
```

### Timeout errors

Increase the timeout for slow operations:

```bash
playwriter -s 1 --timeout 30000 -e 'await page.goto("https://slow-site.com")'
```

The default timeout is 10 seconds.

### Port 19988 already in use

Another instance of the relay is running. Kill it first:

```bash
# macOS/Linux
PIDS=$(lsof -ti :19988)
[ -n "$PIDS" ] && kill $PIDS

# Or use --replace flag
playwriter serve --token MY_SECRET --replace
```

### Skill recorder will not start, or stop is ambiguous

The in-page **Record** button talks to the local relay. It no longer fails just because several playwriter sessions are open. If start still fails:

1. Is the extension icon green on the tab?
2. Is the relay up? Run any `playwriter` command or `playwriter serve`
3. Read `playwriter logfile` and look for `Record start endpoint error`

If `playwriter recorder stop` says **Multiple active recordings**, it prints each recording id with the current or last page URL. Pick the matching one:

```bash
playwriter recorder status
playwriter recorder stop 3
```

See [Skill Recorder](/docs/skill-recorder) for the full command list and more cases.

### Dialogs (alert, confirm, prompt) disappear instantly

Playwright **auto-dismisses** JavaScript dialogs when no handler is registered. This is core Playwright behavior, not Playwriter-specific. When a dialog opens and nothing handles it:

* **alert** is dismissed (same as clicking OK)
* **confirm** is dismissed (same as clicking **Cancel**)
* **prompt** is dismissed (same as clicking **Cancel**)
* **beforeunload** is accepted (lets navigation proceed)

This happens because an open JS dialog **blocks all JavaScript execution** on the page. Every `evaluate()`, click, and navigation hangs until the dialog is closed. Playwright auto-dismisses to prevent the page from freezing.

The consequence for agents: `confirm()` dialogs always return `false` (cancelled) unless the agent explicitly handles them.

**Fix:** register a dialog handler *before* the action that triggers the dialog:

```js
// Must be set up BEFORE clicking the button that opens the dialog
state.page.on('dialog', async (dialog) => {
  console.log(`Dialog: ${dialog.type()} - ${dialog.message()}`)
  await dialog.accept()  // or dialog.dismiss()
})
await state.page.click('button.delete')
```

The handler must be registered first because Playwright fires the auto-dismiss synchronously when Chrome sends the CDP event. If there is no handler at that exact moment, the dialog is already gone.


---
title: Playwriter vs Chrome Direct CDP
url: "https://playwriter.dev/docs/vs-chrome-cdp.md"
description: "Why Chrome's built-in CDP remote debugging is impractical for agents, and how Playwriter solves it."
---

Chrome has a built-in remote debugging mode that exposes the Chrome DevTools Protocol (CDP) over a WebSocket.
You can launch Chrome with `--remote-debugging-port=9222` or enable it at `chrome://inspect/#remote-debugging`.
This is what most automation tools use under the hood.

The problem: **Chrome now shows a permission dialog every time an external app tries to connect.**

<Image src="/chrome-cdp-dialog.jpg" alt="Chrome &#x22;Allow remote debugging?&#x22; permission dialog" width={650} height={360} placeholder="data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAAAwAQCdASoQAAkAAsBMJaQAA3AA/vXxVwRLn7Swfw4LIu9gWpn41EcTlZ6CAAAA" />

The dialog says "An external app wants full control over this Chrome session to debug it" and requires
the user to click **Allow** before any automation can start. The Cancel button is visually
highlighted as the recommended action. This is by design; Chrome wants to prevent background
processes from silently hijacking browser sessions.

This single dialog **breaks every autonomous agent use case.** An agent can't click Allow
on its own. The user must be present, watching, and ready to click every time the agent
reconnects. If Chrome restarts, the dialog appears again. If the connection drops, the dialog
appears again.

## How Playwriter avoids the dialog

Playwriter uses a Chrome extension with `chrome.debugger` permissions instead of external CDP
connections. The extension runs **inside Chrome**, so it never triggers the remote debugging
permission dialog. The user clicks the extension icon once on a tab, and the agent has full
CDP access from that point forward.

No flags, no Chrome restart, no recurring permission prompts.

## Comparison

|                       | Chrome Direct CDP               | Playwriter               |
| --------------------- | ------------------------------- | ------------------------ |
| Permission dialog     | Every connection                | Never                    |
| Autonomous agents     | Blocked by dialog               | Works unattended         |
| Chrome restart needed | Yes (`--remote-debugging-port`) | No                       |
| Login state           | Depends on profile flags        | Already logged in        |
| User intervention     | Required every reconnect        | One-time extension click |
| Extensions available  | Yes                             | Yes                      |
| Full CDP access       | Yes                             | Yes                      |

## Chrome Direct CDP setup

To use Chrome's built-in CDP, you need to either:

1. **Launch Chrome with a flag:** `chrome --remote-debugging-port=9222`
2. **Enable in settings:** navigate to `chrome://inspect/#remote-debugging`

Both approaches expose a WebSocket endpoint that automation tools can connect to.
But the moment any tool connects, Chrome shows the "Allow remote debugging?" dialog.

With Playwriter's `--direct` mode, you can still use Chrome's built-in CDP when you
need it (for example, to access all open tabs at once). But for most agent workflows,
the extension mode is better because it avoids the dialog entirely.

```bash
# Extension mode (recommended): no dialog, no flags
playwriter session new
playwriter -s 1 -e "page.goto('https://example.com')"

# Direct mode: uses built-in CDP, dialog appears once
playwriter session new --direct
playwriter -s 1 -e "page.goto('https://example.com')"
```

## Why this matters for agents

AI agents need to operate **without human babysitting.** A permission dialog that requires
a click defeats the purpose. The agent can't proceed until someone is there to approve it,
and there's no API to auto-accept the dialog.

With Playwriter, the user enables the extension on the tabs they want controlled, then walks
away. The agent can reconnect, navigate, and automate freely. If Chrome restarts, the
extension reconnects automatically when the user opens Chrome again. No dialog, no flags,
no friction.


---
title: Playwriter vs Playwright MCP
url: "https://playwriter.dev/docs/vs-playwright-mcp.md"
description: Why connecting to your existing Chrome beats spawning a fresh one.
---

Playwright MCP is the official MCP server from the Playwright team. It spawns a **new
Chromium instance** every time it starts. This means a clean browser with no login state,
no extensions, and no cookies.

Playwriter connects to your **running Chrome** instead. Same browser you're already using,
with everything already set up.

## Comparison

|               | Playwright MCP        | Playwriter           |
| ------------- | --------------------- | -------------------- |
| Browser       | Spawns new Chrome     | Uses your Chrome     |
| Extensions    | None                  | Your existing ones   |
| Login state   | Fresh (logged out)    | Already logged in    |
| Bot detection | Always detected       | Can bypass           |
| Collaboration | Separate window       | Same browser as user |
| Memory usage  | Double (two browsers) | No extra browser     |
| CDP access    | No                    | Yes                  |

## The fresh browser problem

When Playwright MCP spawns a new browser, you start from zero:

* **No logins.** Gmail, GitHub, Slack, your internal tools; all logged out. The agent can't
  access anything that requires authentication unless you script a full login flow.
* **No extensions.** Ad blockers, password managers, cookie consent auto-accepters; all gone.
  The agent sees the raw, unfiltered web.
* **Bot detection.** A fresh Chromium instance with no history, no extensions, and default
  fingerprints is trivially detected as a bot. CAPTCHAs appear immediately.
* **Extra memory.** Running two Chrome instances eats 500MB-1GB of additional RAM.

## How Playwriter is different

Playwriter runs in your existing Chrome session. Your cookies, extensions, and browsing
history are all there. Sites that require login just work. CAPTCHAs are rare because
your browser looks like a real user's browser; it **is** a real user's browser.

When the agent hits something it can't handle (a CAPTCHA, a consent wall, a two-factor
prompt), you see it in your own browser and can step in to help. The agent picks up
where you left off.

```bash
# Playwright MCP: agent is alone in a fresh browser
# Playwriter: agent works in your browser, you collaborate in real time

playwriter session new
playwriter -s 1 -e "page.goto('https://gmail.com')"
# Already logged in, no credentials needed
```


---
title: Playwriter vs Playwright CLI
url: "https://playwriter.dev/docs/vs-playwright-cli.md"
description: Full Playwright API in your real browser instead of a fresh instance.
---

The Playwright CLI (`npx playwright`) launches a new browser for testing and automation.
It's great for CI/CD pipelines and test suites, but for agent-driven browser control
it has the same fresh-browser limitations as Playwright MCP.

Playwriter gives you the **full Playwright API** but runs it against your existing Chrome.

## Comparison

|                 | Playwright CLI      | Playwriter                    |
| --------------- | ------------------- | ----------------------------- |
| Browser         | Spawns new browser  | Uses your Chrome              |
| Login state     | Fresh (logged out)  | Already logged in             |
| Extensions      | None                | Your existing ones            |
| Captchas        | Always blocked      | Bypass (disconnect extension) |
| Collaboration   | Separate window     | Same browser as user          |
| Capabilities    | Limited command set | Anything Playwright can do    |
| Raw CDP access  | No                  | Yes                           |
| Video recording | File-based tracing  | Native tab capture (30-60fps) |

## Same API, different browser

Playwriter uses the same Playwright API you already know. The difference is where it runs.
Instead of spawning a new browser, your code controls the browser you're already using.

```bash
# Playwright CLI: fresh browser, no state
npx playwright screenshot https://example.com shot.png

# Playwriter: your browser, your cookies, your extensions
playwriter -s 1 -e "page.screenshot({ path: './shot.png', scale: 'css' })"
```

## Raw CDP access

Playwriter exposes the full Chrome DevTools Protocol through `getCDPSession`. This lets
agents set breakpoints, profile performance, intercept network requests, and live-edit
page scripts. The Playwright CLI doesn't expose CDP at all.

```bash
playwriter -s 1 -e "state.cdp = getCDPSession({ page })"
playwriter -s 1 -e "state.cdp.send('Performance.getMetrics').then(console.log)"
```

## Video recording

Playwright CLI records via tracing (file-based, low FPS). Playwriter uses
`chrome.tabCapture` for native 30-60fps recording that survives page navigation.

```bash
playwriter -s 1 -e "recording.start({ page, outputPath: './demo.mp4' })"
playwriter -s 1 -e "page.click('a'); page.waitForLoadState('domcontentloaded')"
playwriter -s 1 -e "recording.stop({ page })"
```


---
title: Playwriter vs BrowserMCP
url: "https://playwriter.dev/docs/vs-browser-mcp.md"
description: One execute tool with full Playwright vs dozens of fixed tools.
---

BrowserMCP exposes browser automation through **12+ dedicated MCP tools**, each with its own
schema and parameters. The agent must learn every tool's interface and the MCP client must
load all their schemas into context.

Playwriter has **one tool**: `execute`. It runs any Playwright code. The agent already knows
Playwright from its training data, so there's nothing new to learn.

## Comparison

|                      | BrowserMCP              | Playwriter               |
| -------------------- | ----------------------- | ------------------------ |
| Tools                | 12+ dedicated tools     | 1 execute tool           |
| API                  | Limited actions         | Full Playwright          |
| Context usage        | High (tool schemas)     | Low                      |
| LLM knowledge        | Must learn custom tools | Already knows Playwright |
| CDP access           | No                      | Yes                      |
| Debugger             | No                      | Yes                      |
| Network interception | No                      | Full                     |

## Context usage

Every MCP tool adds its schema to the system prompt. With 12+ tools, that's thousands of
tokens the agent pays on every request just to know what's available. Playwriter's single
`execute` tool uses minimal schema space. The agent writes Playwright code, which it
already knows from training.

## Capability ceiling

BrowserMCP tools are fixed. If you need to do something the tool authors didn't anticipate,
you're stuck. Playwriter lets agents run **any Playwright code**, plus raw CDP commands
for advanced workflows like debugging, performance profiling, and live code editing.

```bash
# BrowserMCP: limited to what the tools expose
# Playwriter: anything Playwright can do

playwriter -s 1 -e "page.evaluate(() => performance.getEntriesByType('navigation'))"
playwriter -s 1 -e "state.cdp = getCDPSession({ page }); state.cdp.send('Network.enable')"
```


---
title: Playwriter vs Claude Browser Extension
url: "https://playwriter.dev/docs/vs-claude-extension.md"
description: "Any MCP client, full Playwright API, and cross-platform support."
---

The Claude Browser Extension lets Claude interact with your browser through screenshots
and DOM inspection. It's tightly coupled to Claude and uses a screenshot-based approach
for page understanding.

Playwriter works with **any MCP client** (Claude, Cursor, OpenCode, Windsurf, and more) and
uses accessibility snapshots instead of screenshots by default.

## Comparison

|                      | Claude Extension     | Playwriter              |
| -------------------- | -------------------- | ----------------------- |
| Agent support        | Claude only          | Any MCP client          |
| Windows/WSL          | No                   | Yes                     |
| Context method       | Screenshots (100KB+) | A11y snapshots (5-20KB) |
| Playwright API       | No                   | Full                    |
| Debugger             | No                   | Yes                     |
| Live code editing    | No                   | Yes                     |
| Network interception | Limited              | Full                    |
| Raw CDP access       | No                   | Yes                     |

## Context efficiency

Screenshots are expensive. Each one is 100KB+ of image tokens the model must process.
Playwriter's accessibility snapshots are **5-20KB of text** that contain every interactive
element with ready-to-use locators. The agent can parse them without vision, which is
faster and cheaper.

When spatial layout matters (dashboards, image galleries), Playwriter offers
`screenshotWithAccessibilityLabels` which overlays Vimium-style ref labels on interactive
elements. You get visual context with actionable references in one call.

## Cross-platform and client-agnostic

The Claude extension only works with Claude. Playwriter works with any tool that supports
MCP or can call a CLI. Switch from Claude to Cursor to a custom script without changing
your browser setup.

```bash
# Works with any MCP client, any agent
playwriter session new
playwriter -s 1 -e "snapshot({ page })"
playwriter -s 1 -e "page.locator('button:has-text(\"Submit\")').click()"
```

## Advanced capabilities

Playwriter exposes the full Chrome DevTools Protocol. Set breakpoints, step through code,
inspect variables, edit page scripts live, intercept network requests, and profile
performance. None of this is possible with the Claude extension.


---
title: Agent Reference
url: "https://playwriter.dev/docs/skill.md"
description: "Comprehensive instructions for AI agents on how to use Playwriter's sandbox, utilities, and patterns."
---

import SkillDoc from '../../../../playwriter/src/skill.md'

<Note>
  This page is the **full agent reference** for Playwriter. It's intended for AI agents and coding assistants that need to know every available utility, pattern, and best practice. If you're a human looking for a quick overview, start with [Installation](/docs/installation) or [CLI Reference](/docs/cli) instead.
</Note>

To give your agent these instructions automatically, install the **Playwriter skill**:

```bash
npx -y skills add https://playwriter.dev
```

The skill injects the content below into your agent's context so it knows how to use Playwriter without trial and error. Works with OpenCode, Cursor, Claude Code, and any agent that supports skills.

***

## CLI Usage

If `playwriter` command is not found, install globally or use npx/bunx:

```bash
npm install -g playwriter@latest
# or use without installing:
npx playwriter@latest session new
bunx playwriter@latest session new
```

If using npx or bunx always use @latest for the first session command. so we are sure of using the latest version of the package

### Session management

Each session runs in an **isolated sandbox** with its own `state` object. Use sessions to:

* Keep state separate between different tasks or agents
* Persist data (pages, variables) across multiple execute calls
* Avoid interference when multiple agents use playwriter simultaneously

Get a new session ID to use in commands:

```bash
playwriter session new
# outputs: 1
```

**Always use your own session** - pass `-s <id>` to all commands. Using the same session preserves your `state` between calls. Using a different session gives you a fresh `state`.

List all active sessions with their state keys:

```bash
playwriter session list
# ID  State Keys
# --------------
# 1   myPage, userData
# 2   -
```

Reset a session if the browser connection is stale or broken:

```bash
playwriter session reset <sessionId>
```

### Remote access (control browser from another machine)

Playwriter can control a Chrome browser running on a different machine over the internet. The host machine runs `playwriter serve` with a [traforo](https://traforo.dev) tunnel, and the remote machine connects through the tunnel URL.

```bash
# Host machine (has Chrome + extension)
npx -y traforo -p 19988 -- npx -y playwriter serve --token MY_SECRET_TOKEN

# Remote machine
export PLAYWRITER_HOST=https://<tunnel-id>-tunnel.traforo.dev
export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
playwriter session new
playwriter -s 1 -e "await page.goto('https://example.com')"
```

For the full guide (Docker, LAN, MCP config, security), see: [https://playwriter.dev/docs/remote-access](https://playwriter.dev/docs/remote-access)

### Direct CDP connection (no extension needed)

Playwriter can connect directly to a Chrome instance via the Chrome DevTools Protocol, bypassing the browser extension entirely. This is useful for:

* Chrome running with remote debugging enabled (CI, Docker, headless environments)
* Cloud browser providers that expose a CDP endpoint (e.g. `wss://xxx.cdp.browser-use.com`)
* Any service or machine that gives you a `ws://` or `wss://` URL to a Chrome DevTools session

**Prerequisites:** you need a CDP-enabled Chrome. Either:

* Open `chrome://inspect/#remote-debugging` in Chrome
* Launch Chrome with `--remote-debugging-port=9222`
* Use `playwriter browser start` (enables debugging automatically)
* Use a cloud browser provider URL (no local Chrome needed)

**CLI usage:**

```bash
# Auto-discover local Chrome instances with debugging enabled
playwriter session new --direct

# Connect to a specific CDP endpoint (local or cloud browser provider)
playwriter session new --direct ws://localhost:9222/devtools/browser/...
playwriter session new --direct wss://xxx.cdp.browser-use.com

# Connect to a remote Chrome instance (host:port auto-resolves to ws://)
playwriter session new --direct 192.168.1.50:9222

# Then use the session normally
playwriter -s 1 -e "await page.goto('https://example.com')"
```

**MCP configuration** (for AI assistants): set the `PLAYWRITER_DIRECT` env var in your MCP client config. If the user provides a CDP URL (like `wss://xxx.cdp.browser-use.com`), use it as the value:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "wss://xxx.cdp.browser-use.com"
      }
    }
  }
}
```

`PLAYWRITER_DIRECT` accepts:

* `1` — auto-discover Chrome on port 9222
* `ws://` or `wss://` URL — explicit WebSocket endpoint (local or cloud browser provider)
* `host:port` — resolves via HTTP probe to a ws\:// URL

**Limitations:** screen recording (`recording.start`/`recording.stop`) is not available in direct CDP mode since it relies on the extension's `chrome.tabCapture` API.

### Headless browser (no extension, no user browser)

Launch a headless Chrome automatically. No extension setup, no user browser involvement. Useful when the user doesn't want their personal browser used, in CI/server environments, or for fully autonomous automation.

```bash
# Install Chrome for Testing (first time only, if no Chrome is available)
playwriter browser install

# Launch headless Chrome and create a session
playwriter session new --browser headless

# Use the session normally
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
```

Multiple sessions reuse the same headless Chrome process. Recording is not available in headless mode.

If no Chrome binary is found, `playwriter session new --browser headless` will tell you to run `playwriter browser install` first to download Chrome for Testing.

### Cloud browsers (stealth, proxies, CAPTCHA solving)

Cloud browsers are full Chromium instances running in the cloud. They work exactly like a local Chrome session but with stealth and anti-detection built in. No local Chrome or extension needed.

**When to use cloud browsers:**

* **CAPTCHA bypass.** Cloudflare Turnstile, reCAPTCHA v2/v3, and hCaptcha are solved automatically via token injection. No API keys, no manual solving, no extra code.
* **Anti-detection.** Stealth Chromium patches remove `navigator.webdriver`, CDP leak fingerprints, and other automation signals. Sites that block Playwright, Puppeteer, or Selenium work normally.
* **Residential proxies.** Route traffic through residential IPs in 195+ countries with `--proxy <region>`. Proxy is disabled by default to save cost; enable it only when you need anti-detection or geo-targeting.
* **VPS and headless environments.** Run browser automation from any server without installing Chrome. The cloud browser runs remotely and you connect via CDP.
* **Parallel execution.** Spin up multiple cloud browsers to run tasks in parallel with subagents. Each browser is an isolated instance with its own IP, fingerprint, and cookie jar.
* **Multiple identities.** Control separate logged-in accounts on the same site simultaneously. Each cloud browser has independent cookies and storage, so sessions don't interfere with each other.

**Authentication:** two options depending on your environment.

```bash
# Option 1: Interactive login (opens browser for OAuth)
playwriter cloud login

# Option 2: API key (for CI, VPS, headless — no browser needed)
# Create one at https://playwriter.dev/dashboard, then:
export PLAYWRITER_API_KEY=pw_xxxxx
```

```bash
# Check active cloud sessions
playwriter cloud status

# Start a cloud browser session (no proxy, cheapest)
playwriter session new --browser cloud

# Start with US residential proxy (for anti-detection / geo-targeting)
playwriter session new --browser cloud --proxy us

# Use a different region
playwriter session new --browser cloud --proxy de

# Use a custom proxy
playwriter session new --browser cloud --custom-proxy user:pass@host:8080
```

Cloud sessions auto-stop after 10 minutes of inactivity. When proxy is enabled, raster images are blocked by default to reduce bandwidth costs. Pass `--disable-proxy-bandwidth-acceleration` if you need images to load.

### Execute code

```bash
playwriter -s <sessionId> -e "<code>"
```

The `-s` flag specifies a session ID (required). Get one with `playwriter session new`. Use the same session to persist state across commands.

**Execution timeout:** default is 10000ms. Override per call with `--timeout <ms>`, or set a new default via env:

```bash
# One-off longer timeout 
playwriter -s 1 --timeout 120000 -e '...'

# Default for all -e/-f in this shell
export PLAYWRITER_EXEC_TIMEOUT=30000
playwriter -s 1 -e '...'
```

PLAYWRITER\_EXEC\_TIMEOUT is the default fallback. --timeout overrides it, and MCP clients can set the env var or pass a per-call timeout.
**Examples:**

```bash
# Navigate to a page
playwriter -s 1 -e 'state.page = await context.newPage(); await state.page.goto("https://example.com")'

# Click a button
playwriter -s 1 -e 'await state.page.click("button")'

# Get page title
playwriter -s 1 -e 'await state.page.title()'

# Take a screenshot
playwriter -s 1 -e 'await state.page.screenshot({ path: "/absolute/path/to/screenshot.png", scale: "css" })'

# Get accessibility snapshot
playwriter -s 1 -e 'await snapshot({ page: state.page })'

# Get accessibility snapshot for a specific iframe
playwriter -s 1 -e 'const frame = await state.page.locator("iframe").contentFrame(); await snapshot({ frame })'
```

**Why single quotes?** Always wrap `-e` code in single quotes (`'...'`) to prevent bash from interpreting `$`, backticks, and other special characters inside your JS code. Use double quotes or backtick template literals for strings inside the JS code.

**Multiline code:**

```bash
# Preferred: use heredoc with quoted delimiter (disables all bash expansion)
playwriter -s 1 -e "$(cat <<'EOF'
const links = await state.page.$$eval('a', els => els.map(e => e.href));
console.log('Found', links.length, 'links');
const price = text.match(/\$[\d.]+/);
EOF
)"

# Alternative: $'...' syntax (but beware: \n and \t become special, and
# single quotes inside must be escaped as \')
playwriter -s 1 -e $'
const title = await state.page.title();
const url = state.page.url();
console.log({ title, url });
'
```

**Quoting rules summary:**

* **Single quotes** (`'...'`): best for one-liners. No bash expansion at all. But you cannot include a literal single quote inside — use double quotes for JS strings instead.
* **Heredoc** (`<<'EOF'`): best for multiline code. The quoted `'EOF'` delimiter disables all bash expansion. Any character works inside, including `$`, backticks, and single quotes.
* **`$'...'`**: allows `\'` escaping but `\n`, `\t`, `\\` become special — conflicts with JS regex patterns.

### Execute from file

For longer scripts, use `-f` instead of `-e` to execute JavaScript from a file:

```bash
playwriter -s 1 -f script.js
```

The file is read from disk and executed in the same sandbox as `-e`. All context variables (`state`, `page`, `context`, etc.) are available. `-e` and `-f` cannot be used together.

### Recording user actions for skill generation

Before any recorder work, run `playwriter skill` once and read the full output (never truncate).

The user can start recording from the **in-page toolbar** (Record) or ask you to run `playwriter recorder start`. Both write the same event file. The toolbar does not pick a session; the relay attaches to any free extension session (or creates one). Session choice does not matter: extension sessions share the same Chrome tabs. You identify the recording later at **stop** time.

`playwriter recorder start` records everything the user does in the browser (clicks, typing, navigations, mutating xhr/fetch) as events with generated locator strings. It also saves a jpeg of each visual change into a frames folder (`~/.playwriter/recordings/<id>/frames`, files named `<ms>.jpg`). User clicks flash a ripple in those frames. To see the screen at an event, read the jpeg whose filename is closest to that event's `ms`. When the user asks you to "start recording", run it and let them perform their workflow. You may run playwriter commands on that session if they ask (snapshot, inspect, click something). If they did not ask, ask first. Do not drive the workflow yourself.

```bash
playwriter recorder start            # reuse the only session, or create one
playwriter recorder start -s 1       # attach to an existing session
playwriter recorder status           # active recordings + current page urls
playwriter recorder stop             # stop the only active recording
playwriter recorder stop 3           # stop recording 3 when several are active
playwriter recorder events           # thin timeline of the latest recording
playwriter recorder events -r 3      # events of recording 3
playwriter recorder events 4 7       # full details of events 4 and 7
```

Run `playwriter recorder stop` when they say done, then `playwriter recorder events -r <id>` to read the events. If stop fails because **more than one recording is active**, the error lists each recording id, session, and current or last page URL. Pick the one that matches the workflow (or ask the user), then `playwriter recorder stop <id>`. Replay the flow with **playwriter** commands only (`playwriter -s <id> -e '...'`), never raw Playwright. The `recorder start` output prints full instructions for turning a recording into a reusable skill: a SKILL.md of markdown instructions with example playwriter commands, plus an importable helper script (`submit.js`, `sdk.js`) for cheap replay. Recording runs inside the relay daemon, so it survives CLI exits. Pass `-s <id>` to record an existing session; the recorder attaches to all Playwriter-enabled tabs and does not open a new tab. A recording auto-stops after 20 minutes.

If the user started from the toolbar and then says "done", still run `playwriter recorder stop` (or `stop <id>` if several are listed). The toolbar Stop button also works; it stops the recording it started.

### Live streaming to RTMP (X Live, Twitch, YouTube)

Niche use case: `playwriter stream start|stop|status` streams a tab live to RTMP endpoints via ffmpeg, surviving navigation and running 24/7 after the CLI exits. Docs: [https://playwriter.dev/docs/streaming](https://playwriter.dev/docs/streaming)

### Debugging playwriter issues

If some internal critical error happens you can read the relay server logs to understand the issue. The log file is located in the user home directory:

```bash
playwriter logfile  # prints the log file path
# typically: ~/.playwriter/relay-server.log
```

The relay log contains logs from the extension, MCP and WS server. A separate CDP JSONL log is created alongside it (see `playwriter logfile`) with all CDP commands/responses and events, with long strings truncated. Both files are recreated every time the server starts. For debugging internal playwriter errors, read these files with grep/rg to find relevant lines.

Example: summarize CDP traffic counts by direction + method:

```bash
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
```

If you find a bug, you can create a gh issue using `gh issue create -R remorses/playwriter --title title --body body`. Ask for user confirmation before doing this.

***

# playwriter best practices

Control user's Chrome browser via playwright code snippets. Prefer single-line code with semicolons between statements. Use playwriter immediately without waiting for user actions; only if you get "extension is not connected" or "no browser tabs have Playwriter enabled" should you ask the user to click the playwriter extension icon on the target tab.

**When to use playwriter instead of webfetch/curl:** If a website is JS-heavy (SPAs like Instagram, Twitter, Facebook, etc.), has cookie consent modals, login walls, lazy-loaded content, carousels, or infinite scroll — **always use playwriter**. Simple fetch/webfetch will return an empty HTML shell with no content. Do NOT waste time trying curl, webfetch, or parsing raw HTML from JS-rendered sites. Go straight to playwriter: navigate with a real browser, dismiss modals, then extract what you need via `page.evaluate()` or network interception.

**If Chrome is not running**, the extension can't connect. Start Chrome from the command line before retrying:

```bash
# macOS
open -a "Google Chrome" --args --profile-directory=Default

# Linux
google-chrome --profile-directory=Default &

# Windows (cmd)
start chrome.exe --profile-directory=Default

# Windows (PowerShell)
Start-Process chrome.exe -ArgumentList '--profile-directory=Default'
```

To also enable automatic tab capture for screen recording (no manual extension click needed), add the `--allowlisted-extension-id` and `--auto-accept-this-tab-capture` flags:

```bash
# macOS
open -a "Google Chrome" --args --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture

# Linux
google-chrome --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture &

# Windows
start chrome.exe --profile-directory=Default --allowlisted-extension-id=jfeammnjpkecdekppnclgkkffahnhfhe --auto-accept-this-tab-capture
```

You can collaborate with the user - they can help with captchas, difficult elements, or reproducing bugs.

**Direct CDP mode (no extension needed):** Playwriter can connect directly to Chrome's DevTools Protocol, bypassing the extension. This is useful in CI, Docker, headless environments, when Chrome has `--remote-debugging-port=9222`, or with cloud browser providers (e.g. `wss://xxx.cdp.browser-use.com`). If the user provides a CDP URL, set `PLAYWRITER_DIRECT` in the MCP client config:

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "wss://xxx.cdp.browser-use.com"
      }
    }
  }
}
```

`PLAYWRITER_DIRECT` accepts `1` (auto-discover Chrome on port 9222), a `ws://` or `wss://` endpoint (including cloud browser providers), or `host:port`. Screen recording is not available in direct CDP mode since it relies on the extension's `chrome.tabCapture` API.

## context variables

* `state` - object persisted between calls **within your session**. Each session has its own isolated state. Use to store pages, data, listeners (e.g., `state.page = await context.newPage()`)
* `page` - a default page (may be shared with other agents). Prefer creating your own page and storing it in `state` (see "working with pages")
* `context` - browser context, access all pages via `context.pages()`
* `require` - load Node.js modules (e.g., `const fs = require('node:fs')`)
* `import()` - use Node.js ESM to load local scripts, packages, and built-ins (e.g., `const helpers = await import('./scripts/helpers.js')`). Relative paths resolve from the session cwd
* `importModule` - restricted async import for allowlisted Node.js built-ins (e.g., `const fs = await importModule('node:fs')`)
* Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, `process`, etc.

**Not available in the sandbox:** `__dirname`, `__filename`.

### importing local scripts

Local modules use normal Node.js ESM. Export helper functions from a `.js` or `.mjs` file and pass Playwriter values such as `page` explicitly:

```js
// scripts/page-helpers.mjs
export async function getPageInfo({ page }) {
  return {
    title: await page.title(),
    url: page.url(),
  }
}
```

Load the module from the directory where the Playwriter session was created:

```js
const { getPageInfo } = await import('./scripts/page-helpers.mjs')
console.log(await getPageInfo({ page }))
```

Local modules can use static imports and package imports normally:

```js
// scripts/save-title.mjs
import fs from 'node:fs/promises'
import path from 'node:path'

export async function saveTitle({ page, outputPath }) {
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
  await fs.writeFile(outputPath, await page.title())
}
```

**Security:** Modules loaded with `import()` run with normal Node.js permissions. Only import code you trust. Use sandboxed `require` or `importModule` when you need restricted built-ins and scoped filesystem writes.

**Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.

**Sandboxed `fs` write restrictions:** `require('node:fs')` is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:

* The **directory where `playwriter` CLI was invoked** (the session's cwd)
* `/tmp`
* The OS temp directory (`os.tmpdir()`, e.g. `/var/folders/.../T/` on macOS)

Writing to any other path (e.g. `~/Downloads`, `~/Desktop`) throws `EPERM: operation not permitted, access outside allowed directories`. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.

## rules

* **Initialize state.page first**: see "working with pages" — at the start of a task, assign `state.page` (reuse `about:blank` or create one) and use `state.page` for all automation steps.
* **Multiple calls**: use multiple execute calls for complex logic - helps understand intermediate state and isolate which action failed
* **Never close**: never call `browser.close()` or `context.close()`. Only close pages you created or if user asks
* **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
* **Click before keyboard input in extension mode.** Call `click()` on the target field immediately before `fill()` or `keyboard` methods. CDP sends keyboard input to the browser's OS-focused surface, so DOM focus and `bringToFront()` can still leave text in Chrome's omnibox.
* **Check state after actions**: always verify page state after clicking/submitting (see next section)
* **Clean up only your listeners**: remove listeners you added by event name or handler reference. Never call `removeAllListeners()` because it also removes Playwriter's page error and console listeners.
* **Tracked page errors are automatic**: uncaught errors from pages assigned directly to `state` keys appear in the current or next execute output as `[PAGE ERROR]`. Errors from pages tracked by other sessions are excluded.
* **Always print page logs after every action**: call `getLatestLogs({ page: state.page, sinceLastCall: true })` after every goto, click, or submit to catch console errors and warnings. Do not manually collect `page.on('console')` events; manual listeners miss logs emitted before the listener is attached. The first `sinceLastCall` call returns all buffered logs including startup and hydration errors.
* **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
* **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
* **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `state.page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like animations, tab opens, or async UI updates where no specific selector is available
* **Snapshot before screenshot**: always use `snapshot()` first to understand page state (text-based, fast, cheap). Only use `screenshot` when you specifically need visual/spatial information. Never take a screenshot just to check if a page loaded or to read text content — snapshot gives you that instantly without burning image tokens
* **Always use absolute file paths for Playwright artifact APIs**: for `page.screenshot({ path })`, `locator.screenshot({ path })`, `elementHandle.screenshot({ path })`, `page.pdf({ path })`, `download.saveAs(path)`, and `video.saveAs(path)`, always pass an absolute path. Relative paths are resolved by Playwright client internals, not the sandboxed `fs`, so they may use the relay server cwd instead of your session cwd.
* **Snapshot replaces page.evaluate() for inspection**: do NOT write `page.evaluate()` calls to manually query class names, bounding boxes, child counts, or visibility flags. `snapshot()` already shows every interactive element with its text, role, and a ready-to-use locator. If you catch yourself writing `document.querySelector` or `getBoundingClientRect` inside evaluate — stop and use `snapshot()` instead. Reserve `page.evaluate()` for actions that modify page state (e.g., `localStorage.clear()`, scroll manipulation) or extract non-DOM data (e.g., `window.__CONFIG__`)

## interaction feedback loop

Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.

1. **Open page** — get or create your page, navigate to URL
2. **Observe** — print `state.page.url()` + `snapshot()` + `getLatestLogs({ sinceLastCall: true })`. Always print URL — pages can redirect unexpectedly.
3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
4. **Act** — perform one action (click, type, submit)
5. **Observe again** — print URL + snapshot + page logs to verify the action's effect
6. **Repeat** from step 3 until task is complete

**Always print page logs after every action** using `getLatestLogs({ sinceLastCall: true })`. This returns only new console messages and errors since the last call, so you catch hydration errors, failed network requests, and runtime exceptions without duplicates. The first call returns all buffered logs from the page, including logs emitted before your script started.

```js
// Each step should be a separate execute call:
// Step 1: navigate + observe
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
console.log('URL:', state.page.url())
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
await snapshot({ page: state.page }).then(console.log)
```

```js
// Step 2: act + observe
await state.page.locator('button:has-text("Submit")').click()
console.log('URL:', state.page.url())
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
await snapshot({ page: state.page }).then(console.log)
```

If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.

**Deeper observation** — when snapshots aren't enough to understand what happened, combine snapshot with filtered logs:

```js
// Search for specific errors in all logs (not just since last call)
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })

// Combine snapshot + filtered logs for full picture
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
console.log('UI:', snap)
console.log('Logs:', logs)
```

Use `getLatestLogs({ sinceLastCall: true })` after every action, `getLatestLogs({ search })` for targeted debugging, `state.page.url()` for navigation, screenshots only for visual layout issues.

## common mistakes to avoid

**1. Not verifying actions succeeded**
Always check page state after important actions (form submissions, uploads, typing). Your mental model can diverge from actual browser state:

```js
await state.page.keyboard.type('my text')
await snapshot({ page: state.page, search: /my text/ })
// If verifying visual layout specifically, use screenshotWithAccessibilityLabels instead
```

**2. Assuming paste/upload worked**
Clipboard paste (`Meta+v`) can silently fail. For file uploads, prefer file input:

```js
// Reliable: use file input
const fileInput = state.page.locator('input[type="file"]').first()
await fileInput.setInputFiles('/path/to/image.png')

// Unreliable: clipboard paste may silently fail, need to focus textarea first for example
await state.page.keyboard.press('Meta+v') // always verify with screenshot!
```

**3. Using stale locators from old snapshots**
Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking, then immediately use locators from that output:

```js
await snapshot({ page: state.page, showDiffSinceLastCall: true })
// Now use the NEW locators from this output
```

**4. Wrong assumptions about current page/element**
Before destructive actions (delete, submit), verify you're targeting the right thing:

```js
// Before deleting, verify it's the right item
await screenshotWithAccessibilityLabels({ page: state.page })
// READ the screenshot to confirm, THEN proceed with delete
```

**5. Text concatenation without line breaks**
`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')` between lines:

```js
await state.page.keyboard.type('Line 1')
await state.page.keyboard.press('Enter')
await state.page.keyboard.type('Line 2')
```

**6. Quote escaping in bash**
Bash parses `$`, backticks, and `\` inside double-quoted strings. This silently corrupts JS code. Always use single quotes or heredoc:

```bash
# single quotes — bash passes everything through literally
playwriter -s 1 -e 'await state.page.locator(`[id="_r_a_"]`).click()'

# heredoc for complex code with mixed quotes
playwriter -s 1 -e "$(cat <<'EOF'
await state.page.locator('[id="_r_a_"]').click()
const match = html.match(/\$[\d.]+/g)
EOF
)"
```

**7. Using screenshots when snapshots suffice**
Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues. Use snapshot for text checks:

```js
await snapshot({ page: state.page, search: /expected text/i })
```

**8. Assuming page content loaded**
Even after `goto()`, dynamic content may not be ready:

```js
await state.page.goto('https://example.com')
// Content may still be loading via JavaScript!
await state.page.waitForSelector('article', { timeout: 10000 })
// Or use waitForPageLoad utility
await waitForPageLoad({ page: state.page, timeout: 5000 })
```

**9. Not using playwriter for JS-rendered sites**
Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These return empty HTML shells. Use playwriter directly:

```js
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 8000 })
await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log)
```

**10. Login buttons that open popups**
Popup windows (`window.open` with features, OAuth buttons) are auto-relocated to tabs in the main window by the Playwriter extension. The new tab appears in `context.pages()` and is fully controllable. You will receive a `[WARNING] New page opened from current page (index N, initial url: ...)` message pointing to the new tab — the `initial url` may be `about:blank` for blank-then-scripted popups, so check `context.pages()[N].url()` for the final URL:

```js
await state.page.locator('button:has-text("Login with Google")').click()
await state.page.waitForTimeout(1000)

// New tab is the last page in the context
const pages = context.pages()
const loginPage = pages[pages.length - 1]

// Complete login flow in loginPage, cookies are shared with original page
await loginPage.locator('[data-email]').first().click()
await loginPage.waitForURL('**/callback**')
// Original page should now be authenticated
```

**11. Click times out or does nothing — snapshot to find the blocker**
When a click times out, a **modal or overlay** is likely intercepting pointer events. Do not retry with different selectors or `{ force: true }` — snapshot to find the blocker:

```js
// click timed out → don't retry blindly, find what's blocking
await snapshot({ page: state.page, search: /dialog|modal/i })
// Found modal → interact with it properly (don't just close via X, it may reappear)
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
```

**12. Never use `dispatchEvent` or `{ force: true }` to bypass blockers**
`dispatchEvent(new MouseEvent(...))`, `{ force: true }`, and `element.click()` inside `page.evaluate()` bypass Playwright checks but **do not trigger React/Vue/Svelte handlers** — state won't update. Use snapshot to find the real interactive element:

```js
await state.page.getByRole('radio', { name: 'Node.js' }).click()
```

**13. Over-investigating instead of just interacting**
When something doesn't respond to a click, do NOT start inspecting CDP event listeners, React fibers, canvas pixel data, or writing `page.evaluate()` to read class names and bounding boxes. This wastes massive context. Instead:

1. Take a `snapshot()` — it shows every interactive element and what to click
2. Try a different interaction pattern if `click()` didn't work:
   * **Drawing/annotation tools, canvas paint** → `mouse.down`, move with steps, `mouse.up` (see drag section)
   * **Keyboard-activated modes** → press the shortcut key (snapshot shows tooltip text like "Draw mode D")
   * **Sliders, timeline scrubbers** → drag pattern
   * **Collapsed/toggled toolbars** → click the toggle first, wait, then interact
3. Take another `snapshot()` to see what changed
4. Only investigate DOM internals if correct interaction patterns produce zero response after 2–3 attempts

## accessibility snapshots

```js
await snapshot({ page: state.page, search?, showDiffSinceLastCall? })
```

* `search` - string/regex to filter results (returns first 10 matching lines)
* `showDiffSinceLastCall` - returns diff since last snapshot (default: `true`, but `false` when `search` is provided). Pass `false` to get full snapshot.

Snapshots return full content on first call, then diffs on subsequent calls. Diff is only returned when shorter than full content. If nothing changed, returns "No changes since last snapshot" message. Use `showDiffSinceLastCall: false` to always get full content. When `search` is provided, diffing is disabled by default so the search filters the full content — pass `showDiffSinceLastCall: true` explicitly to combine both. This diffing behavior also applies to `getCleanHTML` and `getPageMarkdown`.

Example output:

```md
- banner:
  - link "Home" [id="nav-home"]
  - navigation:
    - link "Docs" [data-testid="docs-link"]
    - link "Blog" role=link[name="Blog"]
```

Each interactive line ends with a Playwright locator you can pass to `state.page.locator()`.
If multiple elements share the same locator, a `>> nth=N` suffix is added (0-based)
to make it unique.

**Use snapshot locators directly — never invent selectors.** The snapshot output IS the selector. Do not guess CSS selectors or `getByText` when the snapshot already gives you the exact match:

```js
// Snapshot shows: role=radio[name="Nope, Vanilla"]  →  use it directly
await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click()
// Snapshot shows: role=link[name="SIGN IN"]  →  or pass raw string to locator()
await state.page.locator('role=link[name="SIGN IN"]').click()
```

**Beware CSS text-transform**: snapshots show visual text (`heading "NODE.JS"`) but DOM may be `"Node.js"`. Use case-insensitive regex: `getByRole('heading', { name: /node\.js/i })`.

If a screenshot shows ref labels like `e3`, resolve them using the last snapshot:

```js
const snap = await snapshot({ page: state.page })
const locator = refToLocator({ ref: 'e3' })
await state.page.locator(locator!).click()
```

Search for specific elements:

```js
const snap = await snapshot({ page: state.page, search: /button|submit/i })
```

**Scoping snapshots to a specific element** — pass a `locator` instead of `page` to snapshot only a subtree. This dramatically reduces output size when you only care about one section of the page (e.g., the main content area, ignoring the sidebar/header/footer):

```js
// Full page snapshot: ~150 lines (sidebar, nav, header, footer, everything)
await snapshot({ page: state.page })

// Scoped to main: ~20 lines (just the content you care about)
await snapshot({ locator: state.page.locator('main') })

// Scope to a specific form, dialog, or section
await snapshot({ locator: state.page.locator('[role="dialog"]') })
await snapshot({ locator: state.page.locator('form#checkout') })
```

Use this whenever the full page snapshot is dominated by navigation or layout elements you don't need. It saves significant tokens and makes the output much easier to parse.

**Filtering large snapshots in JS** — when `search` isn't enough, filter the string directly: `snap.split('\n').filter(l => l.includes('dialog') || l.includes('error')).join('\n')`

## choosing between snapshot methods

Use `snapshot` for text-heavy pages (forms, articles) — fast, cheap, searchable. Use `screenshotWithAccessibilityLabels` for complex visual layouts (grids, galleries, dashboards) where spatial position matters. Both share the same ref system and can be combined.

## selector best practices

**For unknown websites**: use `snapshot()` - it shows what's actually interactive with stable locators.

**For development** (when you have source code access), prefer stable selectors in this order:

1. **Best**: `[data-testid="submit"]` - explicit test attributes, never change accidentally
2. **Good**: `getByRole('button', { name: 'Save' })` - accessible, semantic
3. **Good**: `getByText('Sign in')`, `getByLabel('Email')` - readable, user-facing
4. **OK**: `input[name="email"]`, `button[type="submit"]` - semantic HTML
5. **Avoid**: `.btn-primary`, `#submit` - classes/IDs change frequently
6. **Last resort**: `div.container > form > button` - fragile, breaks easily

Combine locators for precision:

```js
state.page.locator('tr').filter({ hasText: 'John' }).locator('button').click()
state.page.locator('button').nth(2).click()
```

If a locator matches multiple elements, Playwright throws "strict mode violation". Use `.first()`, `.last()`, or `.nth(n)`:

```js
await state.page.locator('button').first().click() // first match
await state.page.locator('.item').last().click() // last match
await state.page.locator('li').nth(3).click() // 4th item (0-indexed)
```

## working with pages

**Pages are shared, state is not.** `context.pages()` returns all browser tabs with playwriter enabled — shared across all sessions. Multiple agents see the same tabs. If another agent navigates or closes a page you're using, you'll be affected. To avoid interference, **get your own page**.

**Get or create your page (first call):**

On your very first execute call, reuse an existing empty tab or create a new one, and navigate it **in the same execute call**. Store it in `state` and use `state.page` for all subsequent operations instead of the default `page` variable:

```js
// Reuse an empty about:blank tab if available, otherwise create a new one.
// IMPORTANT: always navigate immediately in the same call to avoid another
// agent grabbing the same about:blank tab between execute calls.
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
await state.page.goto('https://example.com')
// Use state.page for ALL subsequent operations
```

**Handle page closures gracefully:**

The user may close your page by accident (e.g., closing a tab in Chrome). Always check before using it and recreate if needed:

```js
if (!state.page || state.page.isClosed()) {
  state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
}
await state.page.goto('https://example.com')
```

**Use an existing page only when the user asks:**

Only use a page from `context.pages()` if the user explicitly asks you to control a specific tab they already opened (e.g., they're logged into an app). Find it by URL pattern and store it in state:

```js
const pages = context.pages().filter((x) => x.url().includes('myapp.com'))
if (pages.length === 0) throw new Error('No myapp.com page found. Ask user to enable playwriter on it.')
if (pages.length > 1) throw new Error(`Found ${pages.length} matching pages, expected 1`)
state.targetPage = pages[0]
```

**List all available pages:**

```js
context.pages().map((p) => p.url())
```

**Popup windows become tabs automatically:**

The extension intercepts Chrome popup windows (`window.open(url, '', 'width=...')`, OAuth login flows) and relocates them into the main window as regular tabs. You don't need cmd+click or `{ modifiers: ['Meta'] }` to avoid popups. When a page opens another, you receive a `[WARNING] New page opened from current page (index N, initial url: ...)` and can access it via `context.pages()[N]`.

## navigation

**Use `domcontentloaded`** for `page.goto()`:

```js
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
await waitForPageLoad({ page: state.page, timeout: 5000 })
```

## common patterns

**Authenticated fetches** - fetch from within page context to include session cookies automatically:

```js
const data = await state.page.evaluate(async (url) => {
  const resp = await fetch(url)
  return await resp.text()
}, 'https://example.com/protected/resource')
```

**Read page cookies via CDP** - use `Network.getCookies` on the page CDP session:

```js
const cdp = await getCDPSession({ page: state.page })
const { cookies } = await cdp.send('Network.getCookies', { urls: [state.page.url()] })
console.log(cookies)
```

MUST use this for page-scoped cookies in extension mode. `Storage.getCookies` is a root-session command and will fail in playwriter.

**NEVER use `Network.clearBrowserCookies` or `Network.clearBrowserCache`** — these CDP commands are **profile-wide destructive operations** that wipe ALL cookies/cache across every domain in the user's Chrome profile. They will log the user out of Gmail, GitHub, and every authenticated session.

**Clear cookies for a specific domain** — use `Network.getCookies` to fetch cookies scoped to URLs, then delete them individually with `Network.deleteCookies`:

```js
const cdp = await getCDPSession({ page: state.page })
const { cookies } = await cdp.send('Network.getCookies', {
  urls: ['https://example.com', 'https://www.example.com'],
})
for (const cookie of cookies) {
  await cdp.send('Network.deleteCookies', { name: cookie.name, domain: cookie.domain })
}
```

**Downloading large data** - console output truncates large strings. Trigger a browser download instead:

```js
// Fetch protected data and trigger download to user's Downloads folder
await state.page.evaluate(async (url) => {
  const resp = await fetch(url)
  const data = await resp.text()
  const blob = new Blob([data], { type: 'application/octet-stream' })
  const a = document.createElement('a')
  a.href = URL.createObjectURL(blob)
  a.download = 'data.json'
  a.click()
}, 'https://example.com/protected/large-file')
// File saves to ~/Downloads - read it from there
```

**Avoid permission-gated browser APIs** - some APIs require user permission prompts or special browser flags. These often fail silently or hang. Examples to avoid:

* `navigator.clipboard.writeText()` - requires permission
* Multiple concurrent downloads - browser may block
* `window.showSaveFilePicker()` - requires user gesture
* Geolocation, camera, microphone APIs

Instead, use simpler alternatives (single download via `a.click()`, store data in `state`, etc).

**Downloads** - capture and save:

```js
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
```

**iFrames** - two approaches depending on what you need:

```js
// frameLocator: for chaining locator operations (click, fill, etc.)
const frame = state.page.frameLocator('#my-iframe')
await frame.locator('button').click()

// contentFrame: returns a Frame object, needed for snapshot({ frame })
const frame2 = await state.page.locator('iframe').contentFrame()
await snapshot({ frame: frame2 })
```

**Dialogs** - handle alerts/confirms/prompts:

```js
state.page.on('dialog', async (dialog) => {
  console.log(dialog.message())
  await dialog.accept()
})
await state.page.click('button.trigger-alert')
```

**Handling page obstacles (cookie modals, login walls, age gates)** - most major websites show blocking overlays. Always check for these with `snapshot()` right after navigation and dismiss them before doing anything else:

```js
// After navigating, check for common obstacles
await waitForPageLoad({ page: state.page, timeout: 5000 })
const snap = await snapshot({
  page: state.page,
  search: /cookie|consent|accept|reject|decline|allow|age|verify|login|sign.in/i,
})
console.log(snap)
// Look for dismiss/accept/decline buttons in the snapshot, then click them:
// await state.page.locator('button:has-text("Accept")').click();
// await state.page.locator('button:has-text("Decline optional")').click();
// Then re-snapshot to confirm the modal is gone before proceeding
```

If the page requires login and the user is already logged into Chrome, their session cookies are available — just navigate and the page should load authenticated. If not, ask the user for help or use their existing logged-in tab via `context.pages()`.

**Extracting and downloading media (images, videos)** - use `page.evaluate()` to extract URLs from the rendered DOM, then download via Node.js in the sandbox. This is far more reliable than parsing raw HTML:

```js
// Extract all image URLs from rendered DOM
const images = await state.page.evaluate(() =>
  Array.from(document.querySelectorAll('img[src]')).map((img) => ({
    src: img.src,
    alt: img.alt,
    width: img.naturalWidth,
  })),
)
console.log(JSON.stringify(images, null, 2))

// Download a specific image to disk
const fs = require('node:fs')
const resp = await fetch(images[0].src)
const buf = Buffer.from(await resp.arrayBuffer())
fs.writeFileSync('./downloaded-image.jpg', buf)
console.log('Saved', buf.length, 'bytes')
```

For carousels or lazy-loaded galleries, you may need to click navigation arrows or scroll first, then re-extract. Use network interception (see "network interception" section) to capture high-resolution CDN URLs that may differ from the `img.src` thumbnails.

## utility functions

**getLatestLogs** - retrieve captured browser console logs and page errors (up to 5000 per page):

Always use this helper when inspecting browser logs. Do not attach new `page.on('console')` listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.

Uncaught errors from pages assigned directly to `state` keys also appear automatically in execute output. `getLatestLogs()` keeps the full page log history for filtering and deeper diagnosis.

Use `sinceLastCall: true` after every action to get only new logs since the previous call. The first call returns all buffered logs including pre-existing ones. Logs persist across navigations so you never miss errors from page transitions.

```js
await getLatestLogs({ page?, count?, search?, sinceLastCall? })
// After every action: get only new logs
const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true })
// Search all logs (ignores cursor):
const errors = await getLatestLogs({ search: /error/i, count: 50 })
const pageLogs = await getLatestLogs({ page: state.page, count: 100 })
const hydrationErrors = await getLatestLogs({ page: state.page, search: /hydration|pageerror|React/i })
```

**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:

```js
await getCleanHTML({ locator, search?, showDiffSinceLastCall?, includeStyles? })
// Examples:
const html = await getCleanHTML({ locator: state.page.locator('body') })
const html = await getCleanHTML({ locator: state.page, search: /button/i })
const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall: false })  // disable diff
```

**Parameters:**

* `locator` - Playwright Locator or Page to get HTML from
* `search` - string/regex to filter results (returns first 10 matching lines with 5 lines context)
* `showDiffSinceLastCall` - returns diff since last call (default: `true`, but `false` when `search` is provided). Pass `false` to get full HTML.
* `includeStyles` - keep style and class attributes (default: false)

Cleans HTML automatically: removes script/style/svg/head tags, unwraps empty wrappers, removes empty elements, truncates long values. Keeps semantic attributes (`href`, `name`, `type`, `aria-*`, `data-*`).

**getPageMarkdown** - extract main page content as plain text using Mozilla Readability (same algorithm as Firefox Reader View). Strips navigation, ads, sidebars, and other clutter. Returns formatted text with title, author, and content:

```js
await getPageMarkdown({ page: state.page, search?, showDiffSinceLastCall? })
// Examples:
const content = await getPageMarkdown({ page: state.page, showDiffSinceLastCall: false })  // full article
const matches = await getPageMarkdown({ page: state.page, search: /API/i })  // search within content
```

**Output format:**

```
# Article Title

Author: John Doe | Site: example.com | Published: 2024-01-15

> Article excerpt or description

The main article content as plain text, with paragraphs preserved...
```

**Parameters:**

* `page` - Playwright Page to extract content from
* `search` - string/regex to filter content (returns first 10 matching lines with 5 lines context)
* `showDiffSinceLastCall` - returns diff since last call (default: `true`, but `false` when `search` is provided). Pass `false` to get full content.

**waitForPageLoad** - smart load detection that ignores analytics/ads:

```js
await waitForPageLoad({ page: state.page, timeout?, pollInterval?, minWait? })
// Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
```

**getCDPSession** - send raw CDP commands:

```js
const cdp = await getCDPSession({ page: state.page })
const metrics = await cdp.send('Page.getLayoutMetrics')
```

**getLocatorStringForElement** - get stable Playwright selector from an element:

```js
const selector = await getLocatorStringForElement(state.page.locator('[id="submit-btn"]'))
// => "getByRole('button', { name: 'Save' })"
```

**getReactSource** - get React component source location (dev mode only):

```js
const source = await getReactSource({ locator: state.page.locator('[data-testid="submit-btn"]') })
// => { fileName, lineNumber, columnNumber, componentName }
```

**getReactComponentInfo** - get best-effort React component info for an element. Returns `null` for non-React elements and never throws just because an element was not rendered by React. Source locations are usually only available in React dev builds. Props are sanitized and truncated so functions, DOM nodes, circular refs, and huge objects do not flood the output.

```js
const info = await getReactComponentInfo({ locator: state.page.locator('[data-testid="submit-btn"]') })
// => { componentName, source, hierarchy, props } | null
```

**inspectPinnedElement** - inspect a Playwriter pinned element and print the element `outerHTML` plus React component info when available. Used by the in-page toolbar and right-click copy flow.

```js
await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
```

**getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.

```js
const styles = await getStylesForLocator({
  locator: state.page.locator('.btn'),
  cdp: await getCDPSession({ page: state.page }),
})
console.log(formatStylesAsText(styles))
```

**createDebugger** - set breakpoints, step through code, inspect variables at runtime. Useful for debugging issues that only reproduce in browser, understanding code flow, and inspecting state at specific points. Can pause on exceptions, evaluate expressions in scope, and blackbox framework code. ALWAYS fetch `https://playwriter.dev/resources/debugger-api.md` first.

```js
const cdp = await getCDPSession({ page: state.page })
const dbg = createDebugger({ cdp })
await dbg.enable()
const scripts = await dbg.listScripts({ search: 'app' })
await dbg.setBreakpoint({ file: scripts[0].url, line: 42 })
// when paused: dbg.inspectLocalVariables(), dbg.stepOver(), dbg.resume()
```

**createEditor** - view and live-edit page scripts and CSS at runtime. Edits are in-memory (persist until reload). Useful for testing quick fixes, searching page scripts with grep, and toggling debug flags. ALWAYS read `https://playwriter.dev/resources/editor-api.md` first.

```js
const cdp = await getCDPSession({ page: state.page })
const editor = createEditor({ cdp })
await editor.enable()
const matches = await editor.grep({ regex: /console\.log/ })
await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString: 'DEBUG = true' })
```

**screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of **20 seconds** for complex pages.

This is only for **finding interactive elements** on the page. To share a screenshot with the user or save an image, use `page.screenshot()` + `resizeImageForAgent()` instead (see "taking screenshots" section below).

Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `snapshot` with search is faster and uses fewer tokens.

```js
await screenshotWithAccessibilityLabels({ page: state.page })
// Image and accessibility snapshot are automatically included in response
// Use refs from snapshot to interact with elements
await state.page.locator('[id="submit-btn"]').click()

// Can take multiple screenshots in one execution
await screenshotWithAccessibilityLabels({ page: state.page })
await state.page.click('button')
await screenshotWithAccessibilityLabels({ page: state.page })
// Both images are included in the response
```

Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.

**resizeImageForAgent** - shrink an image so it consumes fewer tokens when read back into context. The resized image is automatically included in the response (visible to the LLM). `await resizeImageForAgent({ input: '/absolute/path/to/screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `format` (default: `'png'`), `output`. Alias: `resizeImage`.

**recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` so **recording survives page navigation**. Auto-overlays a ghost cursor that follows mouse actions. Requires user to have clicked the Playwriter extension icon on the tab. Auto-resizes viewport to 16:9 (override with `aspectRatio: null`). Auto-stops after 15 min (override with `maxDurationMs`).

For demos, use interaction methods (`locator.click()`, `page.mouse.move()`) instead of `goto()` to show realistic cursor motion.

```js
await recording.start({
  page: state.page,
  outputPath: '/absolute/path/to/recording.mp4',
  frameRate: 30, // default
  audio: false, // default (tab audio)
  videoBitsPerSecond: 2500000,
  aspectRatio: { width: 16, height: 9 }, // default, set null to skip
  maxDurationMs: 15 * 60 * 1000, // default, set 0 to disable
})

// Recording survives navigation
await state.page.click('a')
await state.page.waitForLoadState('domcontentloaded')

// Stop — save full result including executionTimestamps for createDemoVideo
state.recordingResult = await recording.stop({ page: state.page })

// Other: recording.isRecording({ page }), recording.cancel({ page })
```

**ghostCursor.show / ghostCursor.hide** - the ghost cursor overlay is always on: the extension injects it on every Playwriter-attached tab and it stays visible at the last spot Playwright clicked or moved. These methods only matter if you want to change the cursor style or temporarily hide it:

```js
await ghostCursor.show({ page: state.page, style: 'screenstudio' }) // 'minimal' (default), 'dot', 'screenstudio'
await ghostCursor.hide({ page: state.page }) // hide until next show() or hard navigation
```

**createDemoVideo** - speeds up idle sections (time between execute() calls) while keeping interactions at normal speed. Requires `ffmpeg`/`ffprobe`. Timestamps are tracked automatically during recording and returned by `recording.stop()`. **Timeout**: can take 60–120+ seconds, always pass `--timeout 120000` or higher.

```js
// After recording.stop(), save full result to state (executionTimestamps powers idle detection)
state.recordingResult = await recording.stop({ page: state.page })

// In a SEPARATE execute call with --timeout 120000:
const demoPath = await createDemoVideo({
  recordingPath: state.recordingResult.path,
  durationMs: state.recordingResult.duration,
  executionTimestamps: state.recordingResult.executionTimestamps,
  speed: 6, // default 6x for idle sections
})
```

## pinned elements

Users can right-click → "Copy Playwriter Element Reference" to store elements in `globalThis.playwriterPinnedElem1` (increments for each pin). The reference is copied to clipboard:

```js
const el = await state.page.evaluateHandle(() => globalThis.playwriterPinnedElem1)
await el.click()
```

## taking screenshots

Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:

```js
await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
```

If you want to read back the image file into context, resize it first so it consumes fewer tokens:

```js
await resizeImageForAgent({ input: './shot.png' })
```

## page.evaluate

Code inside `page.evaluate()` runs in the browser - use plain JavaScript only, no TypeScript syntax. Return values and log outside (console.log inside evaluate runs in browser, not visible):

```js
const title = await state.page.evaluate(() => document.title)
console.log('Title:', title)

const info = await state.page.evaluate(() => ({
  url: location.href,
  buttons: document.querySelectorAll('button').length,
}))
console.log(info)
```

## loading files

Fill inputs with file content:

```js
const fs = require('node:fs')
const content = fs.readFileSync('./data.txt', 'utf-8')
await state.page.locator('textarea').fill(content)
```

## network interception

For scraping or reverse-engineering APIs, intercept network requests instead of scrolling DOM. Store in `state` to analyze across calls:

```js
state.requests = []
state.responses = []
state.page.on('request', (req) => {
  if (req.url().includes('/api/')) state.requests.push({ url: req.url(), method: req.method(), headers: req.headers() })
})
state.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 {}
  }
})
```

Then trigger actions (scroll, click, navigate) and analyze captured data:

```js
console.log('Captured', state.responses.length, 'API calls')
state.responses.forEach((r) => console.log(r.status, r.url.slice(0, 80)))
```

Inspect a specific response to understand schema:

```js
const resp = state.responses.find((r) => r.url.includes('users'))
console.log(JSON.stringify(resp.body, null, 2).slice(0, 2000))
```

Replay API directly (useful for pagination):

```js
const { url, headers } = state.requests.find((r) => r.url.includes('feed'))
const data = await state.page.evaluate(
  async ({ url, headers }) => {
    const res = await fetch(url, { headers })
    return res.json()
  },
  { url, headers },
)
console.log(data)
```

Clean up listeners when done: `state.page.removeAllListeners('request'); state.page.removeAllListeners('response');`

## computer use (low-level mouse/keyboard)

### clicking

```js
// Preferred: by locator (stable, auto-waits, no coordinates needed)
await state.page.locator('button[name="Submit"]').click()
await state.page.locator('text=Login').click({ button: 'right' })
await state.page.locator('text=Login').dblclick()
await state.page
  .locator('a')
  .first()
  .click({ modifiers: ['Meta'] }) // cmd+click opens link in new background tab

// By coordinates (when locators aren't available, e.g. canvas, maps, custom widgets)
await state.page.mouse.click(450, 320) // left click
await state.page.mouse.click(450, 320, { button: 'right' }) // right click
await state.page.mouse.dblclick(450, 320) // double click
await state.page.mouse.click(450, 320, { clickCount: 3 }) // triple click
await state.page.mouse.click(450, 320, { modifiers: ['Shift'] }) // shift+click
```

### hover

```js
await state.page.locator('.tooltip-trigger').hover() // by locator (preferred)
await state.page.mouse.move(450, 320) // by coordinates
```

### scroll

```js
// By locator (preferred)
await state.page.locator('#footer').scrollIntoViewIfNeeded()

// By pixel (for canvas, maps, infinite scroll)
await state.page.mouse.wheel(0, 300) // scroll down 300px
await state.page.mouse.wheel(0, -300) // scroll up
await state.page.mouse.wheel(300, 0) // scroll right
await state.page.mouse.wheel(-300, 0) // scroll left

// Scroll at a specific position
await state.page.mouse.move(450, 320)
await state.page.mouse.wheel(0, 500)

// Scroll inside a container
await state.page.locator('.scrollable-list').evaluate((el) => {
  el.scrollTop += 500
})
```

### drag

```js
// By locator (preferred)
await state.page.locator('#item').dragTo(state.page.locator('#target'))

// By coordinates (for canvas, sliders, custom drag targets)
await state.page.mouse.move(100, 200)
await state.page.mouse.down()
await state.page.mouse.move(400, 500, { steps: 10 }) // steps for smooth drag
await state.page.mouse.up()
```

**Freehand drawing, annotation widgets, and canvas tools** use this same `mouse.down → move → up` pattern. If a widget expects a drawn stroke (paint tools, annotation overlays, range sliders, timeline scrubbers), always use held-mouse motion — not `mouse.click()`:

```js
// Draw a stroke across a canvas or annotation layer
await state.page.mouse.move(startX, startY)
await state.page.mouse.down()
await state.page.mouse.move(endX, endY, { steps: 15 }) // steps = smoother stroke
await state.page.mouse.up()
await state.page.waitForTimeout(500) // let the widget process the stroke
```

### key hold / release / repeat

```js
// Hold modifier while pressing another key
await state.page.keyboard.down('Shift')
await state.page.keyboard.press('ArrowDown')
await state.page.keyboard.up('Shift')

// Repeat a key
for (let i = 0; i < 5; i++) await state.page.keyboard.press('ArrowDown')
```

### resize viewport

```js
await state.page.setViewportSize({ width: 1280, height: 720 })
```

### region screenshot (zoom equivalent)

```js
await state.page.screenshot({ path: '/absolute/path/to/region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
```

Prefer locator-based actions over coordinates — locators are stable across scroll/resize, auto-wait for elements, and don't require screenshot round-trips that burn \~800 image tokens per cycle.

## Ghost Browser integration

When running in [Ghost Browser](https://ghostbrowser.com/), the `chrome` object exposes APIs for multi-identity automation (identities, proxies, sessions). See `extension/src/ghost-browser-api.d.ts` for full API reference. Only works in Ghost Browser — calls fail in regular Chrome.


---
title: "Configuration, Environment Variables, and MCP Setup Options"
url: "https://playwriter.dev/docs/configuration.md"
description: "All Playwriter environment variables, CLI flags, MCP server configuration examples for Claude, Cursor, and OpenCode, and remote connection options."
---

Playwriter is configured through **CLI flags** and **environment variables**. No config files needed.

## Environment variables

| Variable             | Description                       | Default                |
| -------------------- | --------------------------------- | ---------------------- |
| `PLAYWRITER_HOST`    | Remote relay server URL           | `ws://localhost:19988` |
| `PLAYWRITER_TOKEN`   | Auth token for remote connections | -                      |
| `PLAYWRITER_DIRECT`  | CDP connection mode (see below)   | -                      |
| `PLAYWRITER_API_KEY` | API key for cloud browsers        | -                      |

### PLAYWRITER\_DIRECT

Connect directly to Chrome's DevTools Protocol, bypassing the extension:

| Value                     | Behavior                              |
| ------------------------- | ------------------------------------- |
| `1`                       | Auto-discover Chrome on port 9222     |
| `ws://...` or `wss://...` | Explicit WebSocket endpoint           |
| `host:port`               | Resolves via HTTP probe to ws\:// URL |

```bash
# Auto-discover local Chrome
PLAYWRITER_DIRECT=1 playwriter session new

# Cloud browser provider
PLAYWRITER_DIRECT=wss://xxx.cdp.browser-use.com playwriter session new
```

### PLAYWRITER\_HOST and PLAYWRITER\_TOKEN

For [remote access](/docs/remote-access), point to a relay server running on another machine:

```bash
export PLAYWRITER_HOST=https://my-tunnel.traforo.dev
export PLAYWRITER_TOKEN=MY_SECRET
playwriter session new
```

## CLI global flags

| Flag                 | Description                                 |
| -------------------- | ------------------------------------------- |
| `-s, --session <id>` | Session ID (required for `-e`, `-f`)        |
| `-e <code>`          | Execute JavaScript code inline              |
| `-f <path>`          | Execute JavaScript from a file              |
| `--host <host>`      | Remote relay server host                    |
| `--token <token>`    | Auth token for remote connections           |
| `--timeout <ms>`     | Execution timeout (default: 10000)          |
| `--patchright`       | Use @playwriter/patchright-core for stealth |

## Session new flags

| Flag                                     | Description                                                    |
| ---------------------------------------- | -------------------------------------------------------------- |
| `--browser <key>`                        | Browser type: extension ID, `headless`, `cloud`, `direct:port` |
| `--direct [endpoint]`                    | Direct CDP: auto-discover or explicit endpoint                 |
| `--proxy <region>`                       | Cloud proxy region (us, de, jp, etc.)                          |
| `--custom-proxy <url>`                   | Custom proxy (host:port or user:pass\@host:port)               |
| `--timeout <minutes>`                    | Cloud browser timeout (1-240, default 60)                      |
| `--disable-proxy-bandwidth-acceleration` | Load images/video with proxy                                   |

## Browser start flags

| Flag                    | Description                  |
| ----------------------- | ---------------------------- |
| `--user-data-dir <dir>` | Persistent profile directory |
| `--headless`            | Headless mode                |
| `--headed`              | Show browser window          |
| `--disable-sandbox`     | Disable Chrome sandbox       |

## Serve flags

| Flag              | Description                            |
| ----------------- | -------------------------------------- |
| `--host [host]`   | Bind host (default: `0.0.0.0`)         |
| `--token <token>` | Auth token (required for public hosts) |
| `--replace`       | Kill existing server on the port       |

## MCP configuration examples

### Basic (extension mode)

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"]
    }
  }
}
```

### Direct CDP

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "1"
      }
    }
  }
}
```

### Cloud browser provider

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_DIRECT": "wss://xxx.cdp.browser-use.com"
      }
    }
  }
}
```

### Remote relay

```json
{
  "mcpServers": {
    "playwriter": {
      "command": "npx",
      "args": ["-y", "playwriter@latest"],
      "env": {
        "PLAYWRITER_HOST": "https://my-tunnel.traforo.dev",
        "PLAYWRITER_TOKEN": "MY_SECRET"
      }
    }
  }
}
```

## Debugging configuration

Playwriter logs to files for debugging:

```bash
# Get log file paths
playwriter logfile
# typically: ~/.playwriter/relay-server.log

# CDP JSONL log for protocol debugging
jq -r '.direction + "\t" + (.message.method // "response")' ~/.playwriter/cdp.jsonl | uniq -c
```

Both files are recreated every time the relay server starts.


---
title: "Skill Recorder: Turn Browser Workflows Into Reusable Agent Skills"
url: "https://playwriter.dev/docs/skill-recorder.md"
description: "Record a browser workflow once by hand, and let your agent turn it into a reusable skill with verified locators, expected outcomes, and an importable utils script."
---

# Skill Recorder

Some workflows are **easier to show than to explain**: submitting a SaaS to 20 directory
websites, filling a legacy enterprise form, navigating an internal dashboard with unusual
UI. Instead of describing every click to your agent, **perform the workflow once** in your
real browser while Playwriter records everything. Your agent then reads the recording and
writes a **skill**: a SKILL.md of markdown instructions with example playwriter commands, plus a utils script for cheap replay.

<Steps>
  <Step title="Start recording">
    Click **Record** on the in-page Playwriter toolbar, or tell your agent
    "start recording". The agent runs `playwriter recorder start`. From this
    moment every click, fill, navigation, and mutating xhr/fetch is captured
    to a JSON event file. A jpeg is saved for each visual change; clicks
    flash a ripple in those frames. Recording attaches to the current
    Playwriter tabs and auto-stops after 20 minutes.
  </Step>

  <Step title="Perform the workflow in your browser">
    Use the app like you normally would, in your real Chrome with your real logins.
    Pauses and dead ends are fine; they get filtered out later.
  </Step>

  <Step title="Say &#x22;done&#x22;">
    The agent reads [https://playwriter.dev/SKILL.md](https://playwriter.dev/SKILL.md) (or runs `playwriter skill`),
    then `playwriter recorder stop` and inspects the events, verifying each
    locator against the live page.
  </Step>

  <Step title="The agent writes the skill">
    It produces a `SKILL.md` with numbered playwriter commands plus a helper
    script (`submit.js`, `sdk.js`), then validates the whole flow by replaying
    that script end-to-end.
  </Step>
</Steps>

## Why record instead of describe

* **You know the app, the agent doesn't.** Recording captures the exact sequence,
  including the parts you'd forget to mention ("click the second row, the first is a header").
* **Real locators, not guesses.** Every action is recorded with the same locator code
  Playwright's own codegen produces: `await page.getByRole('button', { name: 'Submit' }).click()`.
* **The agent sees what you clicked.** Each action is Playwright locator code plus
  mutating xhr/fetch, so the generated skill knows the **expected outcome** of every step.
* **Your logins come free.** Recording happens in your real Chrome with your real
  sessions. No credential handling, no bot walls from a fresh automation browser.

## Quick start

Tell your agent to start recording:

```bash
playwriter recorder start
# Recording 1 started on session 1.
# Events file: ~/.playwriter/recordings/1.json
# ... prints the full skill-authoring instructions for the agent ...
```

Perform the workflow in your browser like you normally would. Take your time; pauses and
mistakes are fine, dead ends get filtered out later. Then tell the agent you're done:

```bash
playwriter recorder stop
# Recording 1 stopped. 47 events captured.
# Events file: ~/.playwriter/recordings/1.json
```

The agent inspects the events. The default view is a **thin timeline**: heavy payloads
(response bodies) show up as sizes, so reading the whole recording costs
few tokens:

```bash
playwriter recorder events | jq -r '[.id, .t, .type, (.code // .url // empty)] | @tsv'
```

```
1   0     recording-started   https://directory.example.com
2   2.1   action              await page.getByRole('link', { name: 'Submit a product' }).click()
3   2.4   navigation          https://directory.example.com/submit
4   3.8   action              await page.getByRole('textbox', { name: 'Product name' }).fill('Acme')
5   5.2   action              await page.getByRole('textbox', { name: 'Website URL' }).fill('https://acme.com')
6   7.0   action              await page.getByRole('button', { name: 'Submit' }).click()
7   7.3   network             POST 200 https://directory.example.com/api/products
```

Then it drills into the events that matter, by id:

```bash
playwriter recorder events 7   # full request postData + responseBody of event 7
```

## How it works

The **goal** is not a Playwright test file. You show a workflow once in your real
Chrome. The agent turns that recording into a **skill**: markdown steps plus
example `playwriter` commands an agent can replay later with your logins.

Recording is a **relay daemon** job. The CLI or toolbar only toggles it. Closing
the terminal does not stop it.

```diagram
You (Chrome)                                   Agent (CLI)
     │                                              │
     │  toolbar Record                              │  playwriter recorder start
     ▼                                              ▼
POST /recorder/start                         POST /recorder/start
(no sessionId)                               (-s optional)
     │                                              │
     └─────────────────────┬────────────────────────┘
                           ▼
                    Relay picks a session
                    (free extension session,
                     or creates one)
                           │
                           ▼
                 Playwright API recorder
                 ~/.playwriter/recordings/<id>/
                           │
     Stop                  │           playwriter recorder stop
     (its recording id)    │                    │
                           ▼                    ▼
                    1 recording  ►  stop it
                    2+ recordings ►  error with page URLs
                                     then: recorder stop <id>
```

### Why session pick barely matters

A playwriter **session** is an isolated sandbox (`state`). Browser **tabs** are
shared across extension sessions. The recorder attaches to one session's
Playwright context so it can listen for clicks. Any extension session sees the
same tabs.

The agent does not need that session id to consume the recording. It uses
`playwriter recorder stop` and `playwriter recorder events`. If two recordings
are active, **stop** lists each one with its current or last page URL so the
agent can pick or ask you.

The only session the toolbar must avoid is a **headless or cloud** session,
which has no user tabs. The relay skips those and creates an extension session
instead.

### Toolbar vs CLI

| Start from                       | What happens                                                                             |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| In-page **Record**               | `POST /recorder/start` with no session id. Never fails just because many sessions exist. |
| `playwriter recorder start`      | Reuses the only session, or creates one. Pass `-s <id>` when several sessions exist.     |
| `playwriter recorder start -s 1` | Attaches to session 1. Fails with 409 if that session is already recording.              |

The toolbar **Stop** button sends the recording id it started, so it is never
ambiguous. `playwriter recorder stop` without an id stops the only active
recording, or errors with URLs if there are several.

## Commands

```bash
playwriter recorder start            # reuse the only session, or create one
playwriter recorder start -s 1       # attach to an existing session
playwriter recorder status           # active recordings + current page urls
playwriter recorder stop             # stop the only active recording
playwriter recorder stop 3           # stop recording 3
playwriter recorder events           # thin timeline of the latest recording
playwriter recorder events -r 3      # events of recording 3
playwriter recorder events 4 7       # full details of events 4 and 7
playwriter recorder events --type action
playwriter recorder events --full    # whole timeline, no size projection
```

`recorder events` prints one JSON object per line (jq-friendly). Default output
is a **thin timeline**: heavy payloads become sizes. Pass event ids to read
full request and response bodies.

```bash
playwriter recorder events | jq -r 'select(.type == "action") | .code'
playwriter recorder events | jq -r 'select(.type == "network") | [.id, .method, .status, .url] | @tsv'
```

## Multiple recordings

Two recordings can be active at once (toolbar + CLI, or two agents). Stop
without an id then **throws** and prints enough to choose:

```
Multiple active recordings. Pass a recording id.

  3  session 1  https://app.example.com/settings
  5  session 2  https://github.com/remorses/playwriter
```

```bash
playwriter recorder status
playwriter recorder stop 3
playwriter recorder events -r 3
```

Ask the user which URL matches the workflow if it is not obvious.

## What gets recorded

Every event is one JSON line with a timestamp (`t`, seconds since start):

| Event type                    | What it captures                                                                                                                              |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `action`                      | Click, fill, press, select, setInputFiles — with generated Playwright locator code                                                            |
| `network`                     | POST/PUT/PATCH/DELETE xhr/fetch only: method, url, status, post data, and **response bodies** for JSON/text. **WebSockets are not recorded.** |
| `download`                    | File downloads: source url and suggested filename                                                                                             |
| `navigation`                  | Full navigations                                                                                                                              |
| `page-opened` / `page-closed` | Popups and new tabs, with page aliases                                                                                                        |
| `console` / `page-error`      | Console errors/warnings and uncaught page errors                                                                                              |
| `signal`                      | Navigation signals tied to an action                                                                                                          |

## What the agent produces

The `recorder start` output tells the agent to read
[https://playwriter.dev/SKILL.md](https://playwriter.dev/SKILL.md) first, inspect
the events, verify locators against the live page, then write a **`SKILL.md`**
plus a helper script named for the flow (`submit.js`, `sdk.js`): markdown
instructions for the flow you performed, with example **playwriter**
commands, and an importable script for cheap replay.

````markdown
# Submit to directory

Preconditions: already signed in. Playwriter drives the browser.

1. Open the submit page

```bash
playwriter -s 1 -e 'await page.goto("https://directory.example.com/submit")'
```

2. Fill name and URL (parameters)

```bash
playwriter -s 1 -e 'const name = "Acme"; await page.getByRole("textbox", { name: "Product name" }).fill(name)'
playwriter -s 1 -e 'const url = "https://acme.com"; await page.getByRole("textbox", { name: "Website URL" }).fill(url)'
```

3. Click Submit. Expect POST `/api/products` and confirmation text.

```bash
playwriter -s 1 -e 'await page.getByRole("button", { name: "Submit" }).click()'
```
````

Replay the helper script (preferred, fewer tokens):

```bash
playwriter -s 1 -e 'const { submitProduct } = await import("./.agents/skills/submit-to-directory/submit.js"); await submitProduct({ page, name: "Acme", url: "https://acme.com" })'
```

The agent validates the skill by running that replay end-to-end before calling it done.

## Use cases

### Reverse engineer a typed API client

Use a site normally — create a job on Midjourney, run a search, export a report — while the
recorder captures every fetch/XHR request **and its response body**. The recording reveals
what no agent can guess: the real request sequence, payload shapes, and response schemas.
The agent then writes a **class SDK** that calls those endpoints with in-page `fetch`
(`page.evaluate`), so cookies, captchas, and Cloudflare stay in the real tab:

```bash
# the site's API surface, extracted from one recorded session
playwriter recorder events | jq -r 'select(.type == "network" and .responseBodySize) | [.id, .method, .url, .responseBodySize] | @tsv'
# then read the full request/response payloads of the interesting endpoints
playwriter recorder events 14 15 22
```

The SDK is a class in `sdk.js`. Shared state (`page`) goes in the constructor.
Methods take one object argument. `fetch` runs inside `page.evaluate`. Failed
calls throw with method, path, status, and response text.

### CRM data entry

Record one full "create lead → fill 20 fields → assign owner → save" flow in Salesforce or
HubSpot. CRMs are deep menus, iframes, and modal choreography that are painful to describe
in words; the recording captures the exact locators (with iframe paths) and the
network events that confirm each step landed. The skill becomes numbered
playwriter commands with parameters `{ name, email, owner }`.

### Bookings driven by incoming messages

Record a booking on your hotel PMS or scheduling tool once: pick dates in the custom
calendar widget, select room type, set the rate, confirm. Custom date pickers are the
classic case where described automation fails and recorded locators succeed. Then connect
the skill to a trigger: an agent reads incoming emails or WhatsApp messages, extracts guest
name and dates, and runs the recorded playwriter steps with those parameters —
verified against the "booking created" network request from the recording.

### Invoice and document harvesting

Once a month someone logs into 15 SaaS billing pages and downloads invoices for accounting.
Record one round per vendor; the recorded `download` events tie each file to the click that
produced it, so the agent writes a skill with a `month` parameter per vendor. The same pattern
covers bank statements, shipping labels, and result PDFs from healthcare or government
portals.

### Internal admin panel operations

Every company has a crusty internal tool: refund a user, extend a trial, toggle a feature
flag, merge duplicate accounts. Record each operation once and commit a skills folder to
the repo — the whole team's agents can now perform them, and the recording doubles as
living documentation. Mutations are verified through the recorded network status codes.

### Repeat submissions

Submit your SaaS to directory sites, cross-post to multiple platforms, file recurring
reports. Record one submission — including logo uploads, captured as `setInputFiles` actions —
and replay it for every launch, adapting locators per site while reusing the flow shape.

### Legacy and enterprise apps

EHRs, payer portals, ERPs, government form wizards: no APIs, unusual UI patterns, strict
field ordering, session timeouts. These flows are far easier to show than to explain. The
recording captures hidden dependencies too — like a field that only appears after another
is filled, visible in the event timeline and frames.

### E2E tests from a manual QA pass

A QA person clicks through a critical flow once; the agent turns the recording into
playwriter commands an agent can replay. Better than codegen alone, because the
recording also contains the **expected outcomes** — mutating network status codes
become response checks.

### Bug reproduction reports

A user hits a bug: "start recording", reproduce it, "done". The event stream — actions,
network failures, `console` errors, `page-error` events — is a complete repro report an
agent can replay and debug against.

### Fixing broken skills

When a site redesign breaks a skill, re-record the flow. The agent diffs the new locators
against the old SKILL.md and utils file and updates only the selectors that changed, keeping the
working parts untouched.

## Troubleshooting

### Record in the toolbar does nothing, or shows an error toast

The button talks to `http://127.0.0.1:19988/recorder/start`. Check:

1. The extension icon is **green** on that tab
2. The relay is running (`playwriter session list` should respond)
3. `playwriter logfile` for `Record start endpoint error`

It no longer fails just because many playwriter sessions are open. It still
fails if the extension is disconnected, or if every extension session is
already recording and a new session cannot be created.

### `playwriter recorder start` asks for `-s`

The CLI still requires `-s` when several sessions exist, because you are
choosing a sandbox to attach to. The toolbar does not. Pass `-s` or let the
user click Record.

### Stop says "Multiple active recordings"

That is expected when two recordings are running. The error lists ids and page
URLs. Stop the one that matches the workflow:

```bash
playwriter recorder stop 3
```

### Recording has no clicks

The recorder only sees tabs where Playwriter is enabled. Click the extension
icon on the tab you are using. Headless and direct-CDP sessions do not record
your real Chrome; use the extension.

### Recording died after 20 minutes

Auto-stop is a safety limit. Start again. Events already written stay in
`~/.playwriter/recordings/<id>.json`.

### Locators from the recording do not work

Do not trust recorded locators blindly. Snapshot the live page and verify each
one. Role names in generated locators may be prefixes of the live accessible
name; they still match. Drop select-all / modifier keypresses that happen just
before a fill.

### Start hangs or errors about a stuck frame

A tab frame never got a document (empty target, some iframes). Start fails in
15s instead of hanging. Close unused iframe-heavy tabs and retry.

## Notes

* Recording runs inside the **relay daemon**, so it keeps recording after the CLI exits
  and survives until you stop it.
* Events persist in `~/.playwriter/recordings/<id>.json` (readable only by your user).
  Cookie values are never stored; storage values and request bodies are truncated.
* Works with multiple tabs and popups: actions carry page aliases so the agent knows
  which page each step targets.

<Aside>
  <Tip>
    The best prompt is simple: **"start recording, I'll show you the workflow"**. The
    agent guesses skill name, location, and parameters from the events, writes the
    SKILL.md and a named helper script, then tells you what it assumed.
  </Tip>
</Aside>


---
title: Cloud Browsers
url: "https://playwriter.dev/docs/cloud-browsers.md"
description: "Stealth Chromium browsers with residential proxies and auto CAPTCHA solving. $10 per browser per month, 2 months free on annual plans."
---

import { CloudHeroSection } from '../../components/hero-section.tsx'

<Above>
  <CloudHeroSection />
</Above>

# Cloud Browsers

Run automation on **stealth Chromium instances** hosted in the cloud. Each cloud browser is a
**separate machine** with its own cookies, storage, IP address, and fingerprint; completely isolated
from your other browsers. They come with **residential proxies**, **automatic CAPTCHA solving**, and
**anti-bot fingerprint evasion** built in. No setup, no proxy configuration, no CAPTCHA API keys
to manage.

Cloud browsers appear alongside your local Chrome in `playwriter session new`, so your existing
workflows and scripts work without changes.

<Aside>
  <Tip>
    Cloud browsers are a paid add-on. Your local Chrome extension stays **free forever**.
  </Tip>
</Aside>

## Pricing

<div className="border border-border rounded-xl p-6">
  |             | **Monthly**     | **Yearly**                      |
  | ----------- | --------------- | ------------------------------- |
  | Per browser | **$10 / month** | **$100 / year** (2 months free) |
</div>

Subscription quantity = max **concurrent** cloud browsers you can run at the same time. Each browser
is an independent machine with its own state. Start with 1 and scale up as needed.
[Subscribe from your dashboard](/dashboard).

<Aside>
  <Note>
    Billing runs on **Stripe**. Update payment method, download invoices, or cancel any time from the portal.
  </Note>
</Aside>

## Why multiple browsers

Each cloud browser is a **separate Chromium machine** with its own IP address, fingerprint, cookies,
localStorage, and auth state. They don't share anything; logging into a site in one browser has zero
effect on the others. Adding more browsers lets you:

* **Run agent tasks in parallel.** Spin up 5 browsers and let 5 agents work simultaneously instead
  of waiting for one to finish before starting the next.
* **Use different identities.** Log into separate accounts on the same site at the same time.
  Useful for testing multi-user flows, managing multiple client accounts, or comparing search
  results across regions.
* **Open more tabs for resource-intensive tasks.** Heavy pages (dashboards, SPAs, video players)
  consume memory and CPU. Spreading tabs across multiple browsers keeps each instance responsive
  instead of slowing everything down in a single browser.

Your subscription quantity controls how many browsers can run **at the same time**. If you need 3
concurrent machines (for example, 3 agents each logged into a different account), set quantity to 3.

## Stealth and anti-detection

Cloud browsers are purpose-built to look like a real user, not a bot.

**Stealth Chromium.** The browser ships with patches that remove common automation signals. Headless
detection checks, `navigator.webdriver`, CDP leak fingerprints, and other bot indicators are all
handled. Sites that block Playwright, Puppeteer, or Selenium out of the box work normally in a
cloud browser.

**Residential proxies.** Proxies are **disabled by default** to keep costs low. Enable them with
`--proxy <region>` when you need anti-detection or geo-targeting. Pick from **195+ countries**.
Sites that block datacenter IPs or rate-limit by IP range see a normal residential connection.

**Automatic CAPTCHA solving.** When a CAPTCHA appears (Turnstile, reCAPTCHA v2/v3, hCaptcha), the
cloud browser solves it automatically via token injection. No CAPTCHA API key needed, no manual
intervention, no extra code in your scripts.

<Aside>
  <Info>
    CAPTCHA solving works for **Cloudflare Turnstile**, **reCAPTCHA v2**, **reCAPTCHA v3**, and
    **hCaptcha**. Other types are not yet supported.
  </Info>
</Aside>

**Custom proxies.** If you have your own proxy infrastructure, pass it directly instead of using
the built-in residential proxies:

```bash
playwriter session new --browser cloud --custom-proxy user:pass@host:8080
```

## Getting started

### 1. Authenticate

**Option A: Interactive login** (opens browser for OAuth)

```bash
playwriter cloud login
```

This opens a device flow in your browser. Sign in at [playwriter.dev](https://playwriter.dev) and
the CLI stores your token locally.

**Option B: API key** (for CI, VPS, headless environments)

Create an API key at [playwriter.dev/dashboard](https://playwriter.dev/dashboard), then set it as an environment variable:

```bash
export PLAYWRITER_API_KEY=pw_xxxxx
```

API keys work everywhere `cloud login` works but don't require a browser or interactive terminal.

### 2. Subscribe

```bash
playwriter cloud subscribe
```

Opens the subscription page where you pick monthly or yearly and set the number of concurrent
browsers.

### 3. Start a cloud browser

```bash
# New cloud browser (no proxy, cheapest)
playwriter session new --browser cloud

# Enable US residential proxy (for anti-detection / geo-targeting)
playwriter session new --browser cloud --proxy us

# German proxy
playwriter session new --browser cloud --proxy de

# Japanese proxy
playwriter session new --browser cloud --proxy jp
```

Cloud sessions appear in the session table with keys like `cloud-1`, `cloud-2`. Selecting an
existing cloud session **reattaches** to the running VM instead of creating a new one.

### 4. Use it

Once connected, the cloud browser works exactly like a local one. All Playwright commands,
snapshots, screenshots, and MCP tools work the same way:

```bash
playwriter -s 1 -e "await page.goto('https://example.com')"
playwriter -s 1 -e "console.log(await snapshot({ page }))"
```

### 5. Check status

```bash
playwriter cloud status
```

Lists all active cloud browser VMs with their index, region, and uptime.

<Aside>
  <Note>
    Cloud sessions **auto-stop after 10 minutes of inactivity** so you don't burn resources when
    you're done.
  </Note>
</Aside>

## MCP and agent usage

Cloud browsers work with the MCP server too. Set env vars in your MCP client config:

```json
{
  "env": {
    "PLAYWRITER_CLOUD_TOKEN": "your-token"
  }
}
```

The MCP server discovers cloud browsers alongside local ones. Agents can request a cloud browser
when they need stealth browsing, geo-targeted access, or CAPTCHA bypass without any extra prompting.

## Subscribe

Manage your subscription from the [dashboard](/dashboard). Pick monthly or yearly, adjust the
number of concurrent machines, and switch or cancel any time through the Stripe billing portal.
