# Parallel Playwright lanes for Claude Code: build kit

Hand this file to Claude Code (or another coding agent) on a **native Linux host**, and ask it
to set this up. It gives every Claude Code session its own authenticated browser on its own
virtual display, watchable and controllable from any device over noVNC.

Companion to the write-up: https://stanshyshkin.com/blog/parallel-playwright-claude-code/
It assumes the single-browser Playwright MCP setup from
https://stanshyshkin.com/blog/playwright-mcp-claude-code/ is already working.

## Fill these in first

- `<your-tailnet-host>`: your machine's Tailscale HTTPS name (e.g. from `tailscale status`).
  If you are not using Tailscale, front the noVNC ports with any reverse proxy that gives you
  HTTPS (HTTPS matters: it is a secure context, which is what makes noVNC clipboard sync work).
- `your-vnc-password`: a VNC password you choose.
- `you` / `/home/you`: your Linux username and home directory.
- Lane count is 16 below; change `MAXLANE`, the `1..16` guard, and the enable loop together to
  use a different number.

## Prerequisites

```bash
sudo apt install -y xvfb x11vnc websockify novnc xfwm4 autocutsel xdotool
# plus: Claude Code CLI, Node.js 18+ (nvm), google-chrome-stable, and Tailscale (or another
# HTTPS reverse proxy). The Playwright MCP browser should already work headless (see the
# "giving Claude Code eyes" post).
```

## Step 1: A browser you can watch: one virtual display + noVNC

`~/bin/playwright-display.sh` (the master display `:20`, also where you log in):

```bash
#!/usr/bin/env bash
set -uo pipefail
export DISPLAY=:20
rm -f /tmp/.X20-lock 2>/dev/null || true

Xvfb :20 -screen 0 1920x1080x24 -nolisten tcp &
sleep 1.5
xfwm4 --replace >/dev/null 2>&1 &          # a window manager, so pages get real windows
sleep 0.5
xsetroot -solid "#10243f" 2>/dev/null || true   # dark-blue idle background

# keep the X clipboard selections in sync so noVNC copy/paste works
autocutsel -selection CLIPBOARD -fork 2>/dev/null || true
autocutsel -selection PRIMARY  -fork 2>/dev/null || true

x11vnc -display :20 -rfbauth "$HOME/.vnc/passwd" -localhost -rfbport 5920 \
       -forever -shared -noxdamage -bg -o "$HOME/.vnc/x11vnc.log"
sleep 0.5

# loopback only; a reverse proxy fronts this over HTTPS
exec websockify --web=/usr/share/novnc 127.0.0.1:6080 localhost:5920
```

Set the VNC password and expose the noVNC port over HTTPS:

```bash
mkdir -p ~/.vnc && x11vnc -storepasswd 'your-vnc-password' ~/.vnc/passwd
tailscale serve --bg --https=443 http://127.0.0.1:6080
# -> https://<your-tailnet-host>/vnc.html
# Note: --https=443 takes over your node's main HTTPS name. If you already serve
# something on 443, pick another port (e.g. --https=8443) and use that in the URL.
```

Point the Playwright MCP at this display instead of headless. In `~/.claude.json`:

```json
"playwright": {
  "command": "npx",
  "args": ["-y", "@playwright/mcp@latest", "--browser", "chrome",
           "--caps", "vision,pdf", "--config",
           "/home/you/.claude/playwright-mcp-config.json"],
  "env": { "DISPLAY": ":20" }
}
```

`~/.claude/playwright-mcp-config.json` (software rendering, since a virtual display has no GPU):

```json
{ "browser": { "launchOptions": { "args": ["--use-gl=swiftshader", "--ignore-gpu-blocklist"] } } }
```

## Step 2: Turn one screen into a pool of lanes

`~/bin/playwright-lane.sh` (the same stack, parameterized by lane number N):

```bash
#!/usr/bin/env bash
# Per-lane display stack. Usage: playwright-lane.sh <N>  (N in 1..16)
#   display :(20+N)   vnc port 5920+N   websocket 6080+N
set -uo pipefail
N="${1:?usage: playwright-lane.sh <1..16>}"
[[ "$N" =~ ^([1-9]|1[0-6])$ ]] || { echo "lane must be 1..16" >&2; exit 2; }

DNUM=$((20 + N)); DISP=":$DNUM"; RFB=$((5920 + N)); WS=$((6080 + N))
export DISPLAY="$DISP"
rm -f "/tmp/.X${DNUM}-lock" 2>/dev/null || true

Xvfb "$DISP" -screen 0 1920x1080x24 -nolisten tcp &
sleep 1.5
xfwm4 --replace >/dev/null 2>&1 &
sleep 0.5
xsetroot -solid "#10243f" 2>/dev/null || true
autocutsel -selection CLIPBOARD -fork 2>/dev/null || true
autocutsel -selection PRIMARY  -fork 2>/dev/null || true

x11vnc -display "$DISP" -rfbauth "$HOME/.vnc/passwd" -localhost -rfbport "$RFB" \
       -forever -shared -noxdamage -bg -o "$HOME/.vnc/x11vnc-lane${N}.log"
sleep 0.5
exec websockify --web=/usr/share/novnc "127.0.0.1:${WS}" "localhost:${RFB}"
```

`/etc/systemd/system/playwright-lane@.service` (so lanes survive reboots):

```ini
[Unit]
Description=Xvfb + x11vnc + noVNC for Playwright lane %i
After=network-online.target
Wants=network-online.target

[Service]
User=you
Environment=HOME=/home/you
ExecStart=/home/you/bin/playwright-lane.sh %i
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target
```

Enable all sixteen and expose each lane's noVNC over HTTPS (lane N on port 8460+N):

```bash
sudo systemctl daemon-reload
for n in $(seq 1 16); do
  sudo systemctl enable --now playwright-lane@$n
  tailscale serve --bg --https=$((8460+n)) http://127.0.0.1:$((6080+n))
done
# -> https://<your-tailnet-host>:846N/vnc.html for each lane
```

## Step 3: One logged-in master profile

Drive a browser on `:20` once and sign in to the sites you use. Open
`https://<your-tailnet-host>/vnc.html` and log in through that view:

```bash
DISPLAY=:20 google-chrome-stable --user-data-dir="$HOME/.cache/playwright-master-profile"
```

Every lane copies this profile, so every session boots already authenticated without ever
handling a password. When a login expires, refresh it here.

## Step 4: The launcher that hands each session a lane

`~/bin/pw-mcp-lane.sh`: wire this as the Playwright MCP `command` instead of the direct `npx`:

```bash
#!/usr/bin/env bash
# Lane-claiming launcher for @playwright/mcp. Gives each Claude session its own
# authenticated browser on its own display + noVNC URL. Falls back to --isolated
# if no lane is free.
set -uo pipefail

MAXLANE=16
HOSTURL="https://<your-tailnet-host>"
MASTER="$HOME/.cache/playwright-master-profile"
STATE="$HOME/.cache/pw-lanes"; BYCLAUDE="$STATE/by-claude"; LOCK="$STATE/lanes.lock"
mkdir -p "$BYCLAUDE"

# nearest ancestor process named exactly "claude" identifies the owning session
claude_pid_of() {
  local p="$1" comm
  for _ in $(seq 1 20); do
    { [ -z "$p" ] || [ "$p" -le 1 ]; } 2>/dev/null && break
    comm=$(awk -F'\t' '/^Name:/{print $2}' "/proc/$p/status" 2>/dev/null)
    [ "$comm" = "claude" ] && { printf '%s' "$p"; return 0; }
    p=$(awk '/^PPid:/{print $2}' "/proc/$p/status" 2>/dev/null)
  done; return 1
}
alive() { [ -n "$1" ] && kill -0 "$1" 2>/dev/null; }

CPID="$(claude_pid_of "$$" || true)"; [ -z "$CPID" ] && CPID="manual-$$"
KEY="$BYCLAUDE/$CPID.json"
lane_owner() { cat "$STATE/lane-$1.owner" 2>/dev/null; }
lane_free() { local o; o=$(lane_owner "$1"); [ -z "$o" ] && return 0; [ "$o" = "$CPID" ] && return 0; alive "$o" && return 1 || return 0; }

# claim a lane, serialized with flock so two sessions never grab the same one
exec 9>"$LOCK"; flock 9
CHOSEN=""
if [ -f "$KEY" ]; then                                # reuse this session's lane on an MCP restart
  L=$(sed -n 's/.*"lane"[: ]*\([0-9]*\).*/\1/p' "$KEY" | head -1)
  [ -n "$L" ] && [ "$(lane_owner "$L")" = "$CPID" ] && CHOSEN="$L"
fi
if [ -z "$CHOSEN" ]; then                             # otherwise the first free lane
  for L in $(seq 1 "$MAXLANE"); do lane_free "$L" && { CHOSEN="$L"; break; }; done
fi
if [ -n "$CHOSEN" ]; then
  PROFILE="$STATE/profile-$CHOSEN"
  URL="$HOSTURL:$((8460 + CHOSEN))/vnc.html"
  printf '%s' "$CPID" > "$STATE/lane-$CHOSEN.owner"
  printf '{"lane":%d,"display":":%d","url":"%s"}\n' "$CHOSEN" "$((20 + CHOSEN))" "$URL" > "$KEY"
else
  printf '{"lane":null,"url":"(no free lane, running --isolated)"}\n' > "$KEY"
fi
flock -u 9; exec 9>&-

# free the lane the moment the MCP exits; a dead owner is also treated as free, so a
# SIGKILL never strands a lane
release() { [ -n "${CHOSEN:-}" ] && [ "$(cat "$STATE/lane-$CHOSEN.owner" 2>/dev/null)" = "$CPID" ] && rm -f "$STATE/lane-$CHOSEN.owner"; rm -f "$KEY"; }
trap release EXIT
trap 'kill -TERM "${MCP:-0}" 2>/dev/null' INT TERM

export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1
ARGS=(--browser chrome --caps vision,pdf --viewport-size 1920x1080 --config "$HOME/.claude/playwright-mcp-config.json")

if [ -n "$CHOSEN" ]; then
  pkill -f -- "--user-data-dir=$PROFILE" 2>/dev/null || true      # clear any orphan on a reclaimed lane
  mkdir -p "$PROFILE"
  rsync -a --delete --exclude 'Singleton*' --exclude '*Cache*' --exclude 'GPUCache' \
    "$MASTER/" "$PROFILE/" 2>/dev/null || true                    # seed from the logged-in master
  rm -f "$PROFILE"/Singleton* 2>/dev/null || true
  export DISPLAY=":$((20 + CHOSEN))"
  npx -y @playwright/mcp@latest "${ARGS[@]}" --user-data-dir "$PROFILE" <&0 &
else
  export DISPLAY=":20"
  npx -y @playwright/mcp@latest "${ARGS[@]}" --isolated <&0 &
fi
# Run the MCP in the background so the EXIT trap can free the lane, then wait for it.
# The `<&0` is required: a bare `cmd &` redirects stdin from /dev/null, which gives the
# stdio MCP instant EOF; `<&0` keeps Claude's stdin pipe attached.
MCP=$!; wait "$MCP"
```

Then, in `~/.claude.json`, replace the direct Playwright command with the wrapper:

```json
"playwright": { "command": "/home/you/bin/pw-mcp-lane.sh" }
```

`chmod +x ~/bin/pw-mcp-lane.sh` and restart Claude Code so it loads the new command.

## Step 5: Report which lane a session got

`~/bin/pw-whoami`: have Claude run this at the start of browser work so it can tell you where
to watch:

```bash
#!/usr/bin/env bash
set -uo pipefail
STATE="$HOME/.cache/pw-lanes"; BYCLAUDE="$STATE/by-claude"
claude_pid_of() {
  local p="$1" comm
  for _ in $(seq 1 20); do
    { [ -z "$p" ] || [ "$p" -le 1 ]; } 2>/dev/null && break
    comm=$(awk -F'\t' '/^Name:/{print $2}' "/proc/$p/status" 2>/dev/null)
    [ "$comm" = "claude" ] && { printf '%s' "$p"; return 0; }
    p=$(awk '/^PPid:/{print $2}' "/proc/$p/status" 2>/dev/null)
  done; return 1
}
CPID="$(claude_pid_of "$PPID" || claude_pid_of "$$" || true)"; [ -z "$CPID" ] && CPID="manual-$$"
KEY="$BYCLAUDE/$CPID.json"
[ -f "$KEY" ] || { echo "No lane yet. Use a browser tool once, then re-run."; exit 1; }
URL=$(sed -n 's/.*"url"[: ]*"\([^"]*\)".*/\1/p' "$KEY")
LANE=$(sed -n 's/.*"lane"[: ]*\([0-9]*\).*/\1/p' "$KEY")
[ "${1:-}" = "--url" ] && { echo "$URL"; exit 0; }
echo "This session's browser: lane ${LANE:-none}"
echo "Watch it live at: $URL"
```

## Step 6 (optional): Reclaim idle lanes

If sessions get SIGKILLed or are restored idle on boot (e.g. tmux restoring `claude --resume`
in every window), they can hold a lane without using it. A `systemd --user` timer every 30
minutes can free any lane whose owner has died or sat idle for hours, killing only that lane's
browser (`pkill -f "profile-N"`) and clearing its `.owner` file, never the Claude session. The
session claims a fresh lane the next time it needs one. Ask Claude to generate this timer + a
small reaper script once the rest is working; it is only needed once you run the pool dry.

## Verify

- `claude mcp list` shows `playwright: ... Connected`.
- In a session, use a browser tool once, then run `pw-whoami`: it should print a lane number
  and a `https://<your-tailnet-host>:846N/vnc.html` URL you can open.
- Start a second session, do the same: it should get a *different* lane, and both browsers
  should run at once without colliding.
