playwriter session new --remote <id> playwriter -s 1 -e "state.page = context.pages()[0]; console.log(await state.page.title())"
#xxx), so the initial viewer-page request and Referer omit it. The viewer JavaScript then uses the id to connect to the tunnel hostname, which the tunnel service and Cloudflare necessarily process.playwriter serve + traforo, which gives a remote machine access to all controlled tabs and requires playwriter installed on the host.┌────────────────────────────────────────────────────────────────────────────────────────┐ │ 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 state.page.goto(url)" │ │ │ │ PLAYWRITER_HOST=https://{id}-tunnel.traforo.dev │ │ PLAYWRITER_TOKEN=<secret> │ └────────────────────────────────────────────────────────────────────────────────────────┘
npx -y traforo -p 19988 -t my-machine -- npx -y playwriter serve --token MY_SECRET_TOKEN
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: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
-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.export PLAYWRITER_HOST=https://my-machine-tunnel.traforo.dev export PLAYWRITER_TOKEN=MY_SECRET_TOKEN
playwriter skill) documents all available APIs. Use playwriter exactly as you would locally:playwriter session new # outputs: 1 playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')" playwriter -s 1 -e "console.log(await snapshot({ page: state.page }))"
playwriter --host https://my-machine-tunnel.traforo.dev --token MY_SECRET_TOKEN -s 1 -e "..."
{ "mcpServers": { "playwriter": { "command": "npx", "args": ["-y", "playwriter@latest"], "env": { "PLAYWRITER_HOST": "https://my-machine-tunnel.traforo.dev", "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN" } } } }
import { chromium } from 'playwright-core' const browser = await chromium.connectOverCDP( 'wss://my-machine-tunnel.traforo.dev/cdp/session1?token=MY_SECRET_TOKEN', ) const page = await browser.contexts()[0].newPage() await page.goto('https://example.com') // Don't call browser.close() - it would close the user's Chrome
-t ID and the same token. From a control machine, loop over the tunnel URLs to run commands across the fleet: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 Promise.all(context.pages().map((p) => p.title())))" done
/extension endpoint only accepts connections from 127.0.0.1. This means playwriter serve always runs on the host, never inside the container.PLAYWRITER_HOST and PLAYWRITER_TOKEN to reach the host relay.┌────────────────────────────────────────────────────────────────────────────────────────┐ │ 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 state.page.goto(url)" │ └────────────────────────────────────────────────────────────────────────────────────────┘
playwriter serve --host 0.0.0.0 --token MY_SECRET_TOKEN
0.0.0.0. A token is required because this exposes the relay to the host network.docker run \ -e PLAYWRITER_HOST=host.docker.internal \ -e PLAYWRITER_TOKEN=MY_SECRET_TOKEN \ myimage
playwriter session new playwriter -s 1 -e "state.page = await context.newPage(); await state.page.goto('https://example.com')"
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) |
host.docker.internal is not provided automatically by Docker Engine. You must add it explicitly:docker run \ --add-host=host.docker.internal:host-gateway \ -e PLAYWRITER_HOST=host.docker.internal \ -e PLAYWRITER_TOKEN=MY_SECRET_TOKEN \ myimage
services: app: build: . environment: - PLAYWRITER_HOST=host.docker.internal - PLAYWRITER_TOKEN=MY_SECRET_TOKEN extra_hosts: - "host.docker.internal:host-gateway"
host-gateway special value (available since Docker Engine 20.10) resolves to the host's gateway IP.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.{ "mcpServers": { "playwriter": { "command": "npx", "args": ["-y", "playwriter@latest"], "env": { "PLAYWRITER_HOST": "host.docker.internal", "PLAYWRITER_TOKEN": "MY_SECRET_TOKEN" } } } }
--add-host=host.docker.internal:host-gateway.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 WebSocket endpoint only accepts connections from 127.0.0.1 or ::1. A remote attacker cannot impersonate the extension even with the token.| 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) |
openssl rand -hex 16-t in traforo to get a random tunnel ID for maximum security# 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