headless-browser — take the shot, disturb nobody
NEVER put a browser window on the maintainer's screen
You share this desktop with a human who is working. A verification run must be invisible: headless,
on a throwaway profile, leaving no window and no tab behind. Popping a visible Chrome is the single most
disruptive thing you can do here, and it is never necessary — shot.mjs does everything the gate
requires without ever drawing a pixel. If you catch yourself about to launch a headful browser, that is
the bug.
This is not a style note. It was a real, repeated complaint (maintainer 2026-07-28: "it keeps opening tabs in my actual real Chrome"), and the cause was a skill file recommending the MCP first.
1. The default: scripts/shot.mjs (puppeteer)
shot.mjs launches its own isolated headless Chrome every run — a fresh
puppeteer_dev_chrome_profile-* temp dir, no shared profile, no collision, no window. It works in the
background unconditionally and cannot disturb the maintainer. It screenshots and runs an in-page
evaluate in one shot, and prints any page/console errors. This is the workhorse for "prove it renders",
responsive checks, and optical review.
# screenshot + assert page state (the eval's completion value prints as json)
node scripts/shot.mjs "http://127.0.0.1:4930/" .adhoc-shots/board-desktop.png \
"({title: document.title, threads: document.querySelectorAll('[data-thread-slug]').length})" \
--w=1440 --h=900 --wait=2500
# narrow viewport for responsive/overflow checks
node scripts/shot.mjs "http://127.0.0.1:4930/" .adhoc-shots/board-narrow.png "" --w=420 --h=880
# a complex in-page routine (occlusion/alignment/optical-center) from a file
node scripts/shot.mjs "$URL" out.png @/tmp/routine.js
Always: capture desktop + narrow widths, read the screenshots back, and check the PAGE ERRORS: line
— a clean render with console errors is not a pass. Exercise the relevant active/idle/error/restart
states, not just first paint.
2. Chrome DevTools MCP — richer, but only because this repo forces it headless
The MCP gives you a real a11y tree and interaction primitives (new_page → navigate_page →
take_snapshot / take_screenshot / list_console_messages / list_network_requests / click /
fill / evaluate_script). Reach for it when you genuinely need to drive the page rather than
photograph it.
Why it is second, and why it used to be a menace.
chrome-devtools-mcpships two hostile defaults:headlessdefaults to false (cli-options.js) so it opens a visible window on the maintainer's desktop, andisolateddefaults to false (index.js) so every agent shares one persistent profile at~/.cache/chrome-devtools-mcp/chrome-profile. Shared-profile collisions then fail everynew_pagewith "The browser is already running … Use --isolated".This repo pins both off in its own
.mcp.json(--headless --isolated, on a pinned version) and disables the argument-less plugin build in.claude/settings.json, becauseenabledPluginsaccepts no flags and so can only ever run headful. Do not re-enable that plugin, and do not launchchrome-devtools-mcpby hand without both flags. If the MCP is unavailable or collides anyway, fall straight toshot.mjs— don't fight it.The
.mcp.jsonis where the browser comes from — Frizz does not supply one. Frizz mounted chrome-devtools into every worker it dispatched, on both backends, until 2026-08-26; it now injects only thefrizzMCP server. So these tools are here because THIS repo configures them (.mcp.json+enabledMcpjsonServersin.claude/settings.json), and a worker dispatched in a repo that configures nothing has no browser at all. Anyone who wants one elsewhere adds it themselves — a project.mcp.json, orclaude mcp add --scope user chrome-devtools -- npx -y chrome-devtools-mcp@<version> --headless --isolated.
Removing an injected style: hold the handle. page.addStyleTag() returns an ElementHandle — remove
THAT (await tag.evaluate((el) => el.remove())). Never sweep querySelectorAll("style") matching on
text content: in dev, Vite injects the entire app CSS as a <style>, so a predicate like "contains
.frizz-todo-row and nowrap" matches the whole stylesheet and deletes it. The page then renders
unstyled and every geometry assertion after it fails for a reason that has nothing to do with your change.
3. Process hygiene — you share this machine
Other agents run QA concurrently against the same machine. Everything you start, you own by exact identity, and you clean up only YOUR identity.
- One browser instance per task, not per screenshot. Reuse a single uniquely named owned session / target / harness instance for every desktop and narrow check in the task.
- Arrange cleanup before launch — a
finally, a shelltrap, or equivalent — so an interrupted or failed QA pass still tears down. Verify the exact owned session/target and its helper-process tree are gone before you rest. - NEVER use a global close, and never a broad
pkill -f.close_all_pages, a barepkill -f chrome, or killing by name will take out another agent's live QA and dev servers. Kill by the exact PID / session id you created. - Never leave a Chrome DevTools MCP helper,
agent-browserdaemon, puppeteer browser, or Chrome/Chromium helper process running after the task that started it.
4. Putting the shot in the handoff
Embed the decisive screenshots (not bulk) with markdown image syntax —
 — NOT SendUserFile (that pushes a file as a deliverable; it is not
inline handoff evidence).
Frizz serves a local image through its origin-gated /local-image route, and the route is deliberately path-unconfined (packages/server/src/local-image.ts): any absolute path that realpath-resolves to a regular .png/.jpg/.jpeg/.gif/.webp file renders — .adhoc-shots/ included. The trap is durability, not authorization: /tmp and os.tmpdir() are emptied at boot, and a handoff card is read hours or days later. Three shots embedded from /tmp on 2026-08-29 had become gray path labels by the time the maintainer read them, because the machine rebooted in between (BlockImage deliberately falls back to the plain path text on a load error). Embed from a path that outlives a reboot: .adhoc-shots/ right where shot.mjs wrote it, or your thread's scratch directory. Keep a concise textual finding beside it; the handoff must still read when images are unavailable.
If a check was skipped (MCP unavailable, a state you couldn't reach), say so plainly — don't imply coverage you didn't have.
Composes with
frizz-stack— boot the thing you are pointing this at.visual-review— this skill gets you the shot; that one tells you how to JUDGE it. Load it for any UI change: it carries the ink-measurement routine for icon-beside-text alignment (every glyph is off by a different amount, so one shared nudge cannot fix a cluster) and the baseline-probe bug that inflates a real 1.2px error into a plausible 3.5px one.optical-spacing— the same law sideways, for the ink gaps in a row of controls.