Playwright Browser Automation
CLI vs MCP
Always use CLI (@playwright/cli) unless you have a specific reason not to. CLI saves snapshots/screenshots to disk — the agent only sees page state when it explicitly reads a file. Token cost stays flat across 50+ steps.
MCP (@playwright/mcp) streams full accessibility trees into context after every action. It's reliable and gives the agent richer page understanding, but has two significant downsides:
- Context burn: 4-10x more tokens than CLI for the same flow. The agent starts losing earlier context around step 12-15.
- Single session: MCP runs one persistent browser session. It does NOT support parallel subagents — they'd all share the same browser state. CLI supports parallel sessions via
-s=<name>.
MCP also handles asset/temp file storage automatically, whereas CLI requires the agent to manage artifacts manually (see Asset Management below).
Use MCP only when:
- Short session (<10 steps) where rich inline page understanding helps
- Sandboxed environment with no shell access (Claude Desktop, web-based assistants)
CLI Core Workflow
Always use --headed unless explicitly told otherwise. The user should be able to see what the browser is doing.
Prefer eval over snapshot→ref for interactions when you already know the target (button text, input name, selector). It's significantly faster — no snapshot, no grep, no ref lookup. Use the snapshot→ref loop for discovery (unknown pages), then switch to eval for acting. Snapshots/screenshots remain the right tool for verification.
The fundamental loop: snapshot → find ref → interact → verify.
# 1. Open browser (always use --headed for visibility)
npx @playwright/cli open "https://example.com" --headed
# 2. Take snapshot — saves YAML with element refs (e21, s4, etc.)
npx @playwright/cli snapshot --filename .pw-tmp/page.yaml
# Then read .pw-tmp/page.yaml to find element references
# 3. Interact using refs from snapshot
npx @playwright/cli fill e4 "user@example.com"
npx @playwright/cli fill e7 "password123" --submit
npx @playwright/cli click e12
# 4. Verify — new snapshot or screenshot after navigation
npx @playwright/cli snapshot --filename .pw-tmp/dashboard.yaml
npx @playwright/cli screenshot --filename .pw-tmp/result.png
Key principle: Only read snapshot files when you need to find element refs or verify content. Don't read them "just in case." Most interactions after the first snapshot just need click <ref> or fill <ref> <text> — zero context cost.
Selective reading: If you know the target element's label, grep the snapshot file instead of reading the whole thing: grep -i "submit" .pw-tmp/page.yaml to find the ref, then click it directly.
CLI Quick Reference
The agent already knows standard Playwright CLI commands (click, fill, goto, press, etc.). This section covers only the less obvious commands and flags.
Browser Lifecycle
npx @playwright/cli open [url] --headed # Visible browser window (prefer this)
npx @playwright/cli open [url] --headed --browser chrome # Use installed Chrome
npx @playwright/cli open [url] --persistent # Persistent profile (cookies survive close)
npx @playwright/cli open [url] --profile ./my-profile # Named persistent profile directory
npx @playwright/cli close # Close browser (ephemeral state lost)
npx @playwright/cli resize 1920 1080 # Resize viewport
Snapshots & Screenshots
npx @playwright/cli snapshot --filename .pw-tmp/snap.yaml # Save to file (ALWAYS use --filename)
npx @playwright/cli screenshot --filename .pw-tmp/shot.png # Named screenshot
npx @playwright/cli screenshot --full-page # Full scrollable page
npx @playwright/cli screenshot e15 --filename .pw-tmp/el.png # Element screenshot
Sessions (Parallel Browsers)
npx @playwright/cli list # List active sessions
npx @playwright/cli -s=session2 open "url" # Named session (isolated browser process)
npx @playwright/cli -s=session2 snapshot # Commands target specific session
npx @playwright/cli close-all # Close all sessions
npx @playwright/cli kill-all # Force kill zombie processes
Tabs
npx @playwright/cli tab-list # List open tabs
npx @playwright/cli tab-new "https://..." # Open new tab
npx @playwright/cli tab-select 1 # Switch to tab by index
npx @playwright/cli tab-close 2 # Close tab by index
Tracing & Recording
npx @playwright/cli tracing-start # Start recording
npx @playwright/cli tracing-stop --filename=".pw-tmp/trace.zip" # Save trace
npx @playwright/cli video-start
npx @playwright/cli video-stop
Dialog Handling
npx @playwright/cli dialog-accept # Accept alert/confirm
npx @playwright/cli dialog-accept "my input" # Accept prompt with text
npx @playwright/cli dialog-dismiss # Cancel/dismiss
Session Management & Auth Persistence
Understanding the persistence model prevents "why did I lose my login?" issues.
How CLI sessions work
When you run open, CLI launches a browser process that stays running. All subsequent commands (click, fill, snapshot) reuse that same browser context — cookies, localStorage, and login state persist within the session. The session lives until you close it or the process dies.
Default (ephemeral): State exists only in memory. When you close, everything is gone. Next open starts fresh.
Persistent mode (--persistent): State writes to disk. Cookies/storage survive across close and re-open. Default location: ~/.cache/ms-playwright/cli-<browser>-profile. Override with --profile <path>.
Why sessions sometimes "lose" state
- You accidentally started a new session without
-s=<name>(or used a different name), getting a fresh browser context - You used
closeon an ephemeral session — all state was discarded - The browser process crashed or was killed, and without
--persistent, state was lost - Session cookies expired naturally (server-side timeout)
Auth via persistent profiles
Playwright cannot reuse your real Chrome profiles (Chrome locks them to one process, and copying them doesn't preserve auth). Instead, use dedicated Playwright profiles that accumulate auth over time — similar to how MCP handles it.
Create two persistent profiles — one for personal, one for work — in a global location (~/.pw-profiles/). These are shared across all projects, so auth accumulates regardless of which project directory the agent runs from.
# Personal context — Google, personal email, shopping, banking, etc.
npx @playwright/cli open "https://messages.google.com" --headed --persistent \
--profile ~/.pw-profiles/personal
# Work context — work email, internal tools, admin panels, etc.
npx @playwright/cli open "https://app.example.com" --headed --persistent \
--profile ~/.pw-profiles/work
How it works:
--persistent --profile <path>writes all cookies, localStorage, and session data to disk- Auth accumulates: log into Gmail once → Google Messages, Drive, Calendar all work in future sessions
- Each profile is independent — personal and work auth never mix
- Profiles live in the user's home directory, not per-project — reusable from any working directory
- If Playwright hits a login wall, stop and ask the user to complete the login interactively
- One session per profile — a persistent profile is locked to one browser process. If another agent needs the same profile, it must wait or use a different one
Choosing which profile
Default to using a profile. If there's any chance the task involves authenticated content (shopping accounts, email, dashboards, anything behind a login), use the appropriate profile. Only skip the profile for purely public content like reading documentation or searching public websites.
| Task | Profile |
|---|---|
| "Check my work email" | ~/.pw-profiles/work |
| "Send a message to Mom" | ~/.pw-profiles/personal |
| "Find the best price for X" | ~/.pw-profiles/personal |
| "Check our Railway dashboard" | ~/.pw-profiles/work |
| "Look up React docs" | No profile (ephemeral) |
| "Scrape this public website" | No profile (ephemeral) |
If unclear whether personal or work, ask the user.
Other auth utilities
npx @playwright/cli cookie-list / cookie-get / cookie-set / cookie-delete / cookie-clear
npx @playwright/cli localstorage-list / localstorage-get / localstorage-set
npx @playwright/cli state-save <path> # Export all cookies + storage to JSON
npx @playwright/cli state-load <path> # Import saved state into current session
Asset Management & Cleanup
Unlike MCP (which manages temp files automatically), CLI requires explicit artifact management. Without it, snapshots and screenshots accumulate and pollute the working directory.
Artifact directory strategy
Always direct artifacts to .pw-tmp/ in the project root (add to .gitignore).
IMPORTANT — Before every Playwright session, run this one-liner to clean up any stale artifacts from previous sessions, then create a fresh directory:
# Clean stale .pw-tmp (if older than 1 day) and create fresh
find .pw-tmp -maxdepth 0 -mtime +1 -exec rm -rf {} + 2>/dev/null; mkdir -p .pw-tmp
This MUST be the first command you run before opening a browser. It prevents orphan buildup from interrupted sessions while preserving artifacts from a session that just ran (< 1 day old).
# All snapshots and screenshots go here
npx @playwright/cli snapshot --filename .pw-tmp/page.yaml
npx @playwright/cli screenshot --filename .pw-tmp/home.png
Naming conventions
- Overwrite-in-place for current state: always write to
.pw-tmp/current.yaml— no buildup, always the latest - Descriptive names when you need history:
.pw-tmp/step3-dashboard.yaml,.pw-tmp/after-login.png
Cleanup
# End of session: clean up all artifacts
rm -rf .pw-tmp/
End-of-session cleanup is still best practice, but the start-of-session self-clean above is the safety net for when it doesn't happen.
.gitignore additions
.pw-tmp/
.pw-profiles/
.playwright-cli/
Context Efficiency Tips
Read snapshots selectively
Don't read the full YAML on every page. If you know the target element's label:
# Instead of reading the whole file, grep for the element
grep -i "submit\|login\|sign in" .pw-tmp/page.yaml
# Find the ref (e.g., e12), then click it directly
npx @playwright/cli click e12
Avoid redundant snapshots
Only snapshot after major state changes (page navigation, dynamic content loads). If nothing changed, reuse existing refs. If a click fails with "Node not found", take a fresh snapshot — refs went stale.
Error recovery pattern
1. Attempt action (click, fill, etc.)
2. If "Element not found" or similar error:
a. Take fresh snapshot
b. Search for the element by label/text
c. Retry with new ref
3. If navigation timeout:
a. Reload page
b. Re-snapshot
c. Continue
Minimize screenshot reads
Most verification doesn't need pixels. Use eval to extract text data:
npx @playwright/cli eval "(el) => el.textContent" e31
# Returns: "Revenue: $1,247" — no image token cost
Reserve screenshots for visual-only checks (graphs, canvas, layout verification).
Heavy pages (e-commerce, SPAs)
Amazon, Home Depot, Walmart, and most modern e-commerce or SPA-heavy pages have DOMs that can defeat find, get_page_text, and snapshot — they can run 200k+ tokens with inline scripts and lazy-loaded tiles. One attempt to confirm is fine, but if you get "too large" errors or empty regions, don't retry the same tools — pivot to run-code with page.evaluate and targeted CSS selectors.
For unfamiliar sites where you don't know the selectors, spawn a quick Explore subagent against the live page to identify the stable selectors (result card container, title, price, link attributes) before writing the extraction script. For well-known targets, write the extraction directly.
Multi-item pattern: Open the browser once, then use goto for each query and re-run the same extraction script — don't open repeatedly.
JS-in-bash comment gotcha: CODE=$(tr '\n' ' ' < script.js) silently breaks if the JS contains // single-line comments — after newlines collapse, everything after // is commented out to the end of the script. Use /* */ block comments, or keep the JS as a single line in a file and cat it directly (no tr collapse).
Inner scroll container gotcha (LinkedIn, Gmail, Notion, many SPAs): window.scrollTo(), window.scrollY, page.mouse.wheel, and Keyboard: End/PageDown all silently no-op when <body> has overflow: hidden and the real scroll happens inside a nested element. Symptom: scrollY stays 0 and document.documentElement.scrollHeight === clientHeight even on a long page. Fix: find the actual scroller and set its scrollTop directly.
// Diagnostic — find the real scroll container
const [scroller] = [...document.querySelectorAll('*')].filter(el => {
const s = getComputedStyle(el);
return (s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10;
});
// Scroll it (e.g., LinkedIn's is `main#workspace`)
scroller.scrollTop = 0; // to top
scroller.scrollTop = scroller.scrollHeight; // to bottom
Run a progressive scroll loop (scrollTop += 600 with waitForTimeout(400) between steps) to trigger lazy loading before extracting text or screenshots. Full-page screenshots often DO work on these pages (Playwright walks the scroll container), but element-ref snapshots capture only what's currently rendered.
Long-page visual review (landing pages, profiles, long articles): Don't use --full-page — the resulting PNG is long and narrow, and when read back by the agent it gets compressed into an unreadable thumbnail. Instead, scroll in viewport-sized chunks and screenshot each chunk, so every image renders at a readable scale.
// Pattern — chunked viewport screenshots
const scroller = document.querySelector('main#workspace') || document.scrollingElement;
const vh = scroller.clientHeight;
for (let i = 0, y = 0; y < scroller.scrollHeight; i++, y += vh * 0.9) {
scroller.scrollTop = y;
await new Promise(r => setTimeout(r, 500));
// then from bash: npx @playwright/cli screenshot --filename .pw-tmp/section-${i}.png
}
Overlap each chunk ~10% so nothing lands on a page boundary. For text-only audits (copy review, profile audit), skip screenshots entirely and return innerText from the scroll container — zero image tokens, perfect fidelity.
Parallel Browsing with Subagents
When the main agent delegates browsing tasks to multiple subagents (via the Agent tool), each subagent runs in its own process but shares the same Playwright CLI state directory. Without coordination, they will fight over the default session — one agent's goto overwrites another's page, snapshots return wrong content, and actions target stale refs.
Solution: Named sessions (-s=<name>)
Each subagent MUST use a unique session name. The -s flag creates an isolated browser process per session.
# Subagent 1
npx @playwright/cli -s=agent1 open "https://site-a.com" --headed
npx @playwright/cli -s=agent1 screenshot --filename .pw-tmp/agent1-home.png
npx @playwright/cli -s=agent1 close
# Subagent 2
npx @playwright/cli -s=agent2 open "https://site-b.com" --headed
npx @playwright/cli -s=agent2 screenshot --filename .pw-tmp/agent2-home.png
npx @playwright/cli -s=agent2 close
How to instruct subagents
When spawning parallel browsing agents, include the session name in the prompt:
"Use Playwright CLI with session name `-s=agent1` for ALL commands.
Example: npx @playwright/cli -s=agent1 open 'https://...' --headed"
Also namespace artifact filenames (.pw-tmp/agent1-*.png) to avoid file collisions.
Playwright MCP is NOT suitable for parallel subagents — it's a single persistent server that would have the same shared-state problem as the default CLI session.
Cleanup after parallel sessions
npx @playwright/cli close-all # Gracefully close all named sessions
npx @playwright/cli kill-all # Force-kill zombie browser processes
MCP Reference (When MCP is Chosen)
MCP handles asset management and session state automatically, but burns through context. If using it, reduce the token footprint:
npx @playwright/mcp --image-responses=omit # Don't stream images in context
npx @playwright/mcp --isolated # Fresh context each session (no disk persistence)
npx @playwright/mcp --user-data-dir=<path> # Use specific profile directory
Snapshot modes: incremental (default, sends DOM diffs — use this), full (entire page every time — avoid), none (manual browser_snapshot calls only — most efficient but requires orchestration).
Session management: Defaults to persistent profiles at ~/.cache/ms-playwright/mcp-<browser>-profile. Use --isolated to start fresh. For long sessions, consider chunking: finish a sub-flow, disconnect/reconnect MCP to clear accumulated context.
Schema overhead: MCP's 26+ tool schemas load ~4,200 tokens at session start before any action. CLI's overhead is ~68 tokens.
CLI Configuration File
Place at .playwright/cli.config.json in your project root. The CLI auto-discovers it.
{
"browserName": "chromium",
"launchOptions": {
"headless": false,
"channel": "chrome"
},
"userDataDir": "~/.pw-profiles/personal",
"contextOptions": {
"viewport": { "width": 1280, "height": 800 }
}
}
Key fields: browserName (chromium/firefox/webkit), launchOptions.channel (chrome/msedge for installed browsers), launchOptions.headless, launchOptions.args, userDataDir, contextOptions.viewport, contextOptions.locale, cdpEndpoint (WebSocket URL for attaching to existing browser).
Override per-invocation: npx @playwright/cli --config=path/to/config.json open "url".
run-code — Advanced Scripting
The argument must be a function expression receiving page — bare statements cause SyntaxError.
# Correct # WRONG
run-code 'async (page) => { return page.title(); }' # run-code 'const t = await page.title();'
Shell quoting: Use single quotes outer, double quotes inside. Arrow functions in double quotes get mangled by shell expansion.
Multi-line code from files — write a .js file and collapse newlines:
CODE=$(tr '\n' ' ' < .pw-tmp/script.js) && npx @playwright/cli run-code "$CODE"
Node APIs: No require() in the VM context, but dynamic import() works:
npx @playwright/cli run-code 'async (page) => {
const fs = await import("node:fs/promises");
await fs.writeFile(".pw-tmp/page.html", await page.content());
}'
Network response interception
page.on("response") inside run-code captures API responses — far more reliable than DOM scraping for SPAs. Set up the listener before navigation.
// .pw-tmp/intercept.js — then run with: CODE=$(tr '\n' ' ' < .pw-tmp/intercept.js) && npx @playwright/cli run-code "$CODE"
async (page) => {
const captured = [];
page.on("response", async (r) => {
if (r.url().includes("/api/graphql")) {
try { captured.push({ url: r.url(), data: await r.json() }); } catch(e) {}
}
});
await page.reload();
await page.waitForLoadState("networkidle");
return captured; // or use await import("node:fs/promises") to write to disk
}
The built-in network command lists URLs/statuses but not response bodies — use run-code for actual data.
Tips & Gotchas
- Always use
--filenamewith a path for snapshots and screenshots. Without it, YAML dumps to stdout (floods context) or files land in.playwright-cli/with auto-generated names. - Element refs (e4, s7) are per-snapshot. After navigation or significant DOM changes, take a new snapshot — old refs are stale.
--submitonfillpresses Enter after filling. Great for search boxes and login forms.- Shadow DOM is invisible to the accessibility tree. Use
evalwithdocument.querySelectorto reach shadow DOM elements. - Bot detection: Neither CLI nor MCP bypasses CAPTCHAs or WAFs. Handle auth via storage state files or persistent profiles rather than interactive login when possible.
- Context still accumulates from conversation history even with CLI. For very long sessions (100+ steps), consider summarizing progress and resetting context.
- Chrome profile locking: You cannot share a Chrome profile directory between your real Chrome and Playwright simultaneously. Close Chrome or use a copy.