@blazediff/agent
Agentic visual regression for BlazeDiff. Auto-discovers routes, captures deterministic screenshots, diffs them against committed baselines with the native BlazeDiff core, and hands ambiguous diffs back to your coding agent (Claude Code, Cursor, Codex) to judge.
The package ships a deterministic CLI (blazediff-agent) plus a portable playbook (SKILL.md) that any coding agent drives. No embedded LLM call, no API key in the default flow - your coding agent supplies the loop, vision, and context engineering.
View on GitHubΒ Β· Landing page
Installation
Global
npm install -g @blazediff/agentLocal
npm install --save-dev @blazediff/agentChromium
First run will prompt to install bundled Playwright Chromium - no sudo, no npx playwright install --with-deps.
blazediff-agent browsers install --check --json # check
blazediff-agent browsers install # install if missingOnboard a coding agent
blazediff-agent onboard installs the BlazeDiff playbook into whatever coding-agent stack lives in your project. Run once per project.
# Auto-detect (Claude Code, Codex, Cursor)
blazediff-agent onboard --json
# Explicit
blazediff-agent onboard --stack codex
blazediff-agent onboard --stack claude,codex
blazediff-agent onboard --stack all
# No coding agent β install the local (Moondream + Qwen) judge
blazediff-agent onboard --stack localPer stack:
| Stack | Target | Scope | Detection signal |
|---|---|---|---|
| Claude Code | <project>/.claude/skills/blazediff/SKILL.md | project | CLAUDE.md or .claude/ |
| Codex | ~/.codex/skills/blazediff/SKILL.md | user-global | AGENTS.md, .codex/, or ~/.codex/ |
| Cursor | <project>/.cursor/rules/blazediff.mdc | project | .cursor/ or .cursorrules |
Codex is user-global because OpenAIβs Codex CLI discovers skills under ~/.codex/skills/<name>/SKILL.md β installing there means /blazediff works in every project on your machine. On a TTY with no detection, the command prompts. Pass --force to overwrite a hand-edited file.
Quickstart from a coding agent
Once onboarded, from Claude Code / Codex / Cursor:
/blazediff --cwd apps/websiteThe skill detects whether youβre authoring (no .blazediff/manifest.json) or checking (manifest exists), runs the right flow end-to-end, and stops to ask for confirmation only before destructive operations (rewriting baselines, masking).
Quickstart from the CLI
author
Author baselines
# 1. Setup: config from your dev script + Chromium + playbook
# (--no-capture: baselines are captured explicitly in step 3 below)
blazediff-agent onboard --no-capture
# 2. Start the configured dev server (waits up to 60s for the port)
blazediff-agent serve-status --detach --json
# 3. Capture baselines in one call - pipe a JSON list of routes
cat <<'EOF' | blazediff-agent capture --stdin --mode baseline --json
[
{"id": "home", "url": "/", "mask": [".timestamp"]},
{"id": "pricing", "url": "/pricing"}
]
EOF
# 4. Stop the dev server (mandatory teardown)
blazediff-agent serve-status --kill --jsonCommit .blazediff/ (config + manifest + baselines).
Commands
| Command | Purpose |
|---|---|
onboard | Interactive setup: write .blazediff/config.json + .gitignore, install Chromium, install the playbook into the detected coding-agent stack (Claude Code, Codex, Cursor, or --stack local), and optionally capture baselines |
discover | BFS-crawl routes from baseUrl (depth 2, β€50 routes) as a fallback when source-walking fails |
capture --stdin | Read a JSON array of routes from stdin, screenshot each, write baselines/actuals + manifest |
check | Re-capture every manifest entry, diff against baseline, emit CheckReport. Uses LangGraph for per-entry parallelism; suspends on ambiguous entries when --judge host and resumes via --apply-judgments |
rewrite <id...> | Re-baseline existing manifest entries (mask/viewport/waitFor preserved) |
diff <id> | Re-diff one entry against its actual capture without re-screenshotting |
manifest | Inspect / list manifest entries (add --harness <name> to attach a harness) |
auth init | Record a login flow via Playwright codegen into .blazediff/harnesses/auth.js (fallback for OAuth/SSO/MFA; simple forms are authored directly) |
serve-status | --detach / --kill / --status against the configured dev server |
browsers install | Install bundled Playwright Chromium |
reset --yes | Wipe .blazediff/ |
All commands accept --json for machine-readable output. Pass -C, --cwd <abs-path> to operate on a sub-directory (e.g. one app inside a monorepo).
The judging model
The heuristic verdict pipeline emits one of four labels per failing entry:
| Label | Meaning | Default action |
|---|---|---|
regression-likely | Confident structural change | Investigate; do not rewrite |
intentional-likely | Confident styling/typographic change | Ask user, then rewrite |
noise-likely | Confident non-deterministic source | Ask user; prefer masking over rewriting |
ambiguous | Heuristic couldnβt classify | Defer to host judge |
For ambiguous, the --judge host backend writes a JudgmentRequest to .blazediff/judgments/<id>/request.json containing:
regions[]- bounding boxes, pixel counts, and change types per detected regionpaths.locator(locator.png) - a ~400 px overview thumbnail with every region outlined in redpaths.tiles(regions.png) - a vertical stack of[baseline | actual]pairs, one row per region, at native resolutionpaths.{baseline,actual,diff}- full-page PNGs as a fallbackheuristicVerdictand fullmanifestEntrycontext
Token discipline. The region tiles are 10-100x smaller than the full-page PNGs. A well-behaved host agent reads regions.png + locator.png first and only falls back to the full-page PNGs if a region clearly continues outside its crop.
The host agent writes its verdict to .blazediff/judgments/<id>/verdict.json:
{
"id": "agent",
"verdict": {
"label": "intentional-likely",
"headline": "Em-dash replaced with hyphen in copy",
"rationale": ["region tile shows only typographic substitution"],
"action": "rewrite-if-intended"
},
"rationale": "Full paragraph explanation...",
"confidence": 0.95
}Then re-run blazediff-agent check --apply-judgments --json to merge verdicts into the report. No re-screenshot.
Masking unstable regions
When a diff is noise-likely - or when a regression-likely / intentional-likely diff is actually caused by something inherently non-deterministic in the page - the right fix is usually a mask, not a rebaseline. A rebaseline just resets the clock on a flake; a mask removes it.
Mask whenever the changing region is:
- An auto-cycling animation: carousels, marquees, demo widgets with
setInterval, video posters, Lottie loops - A third-party iframe or embed: Storybook, YouTube, codesandbox, Stripe checkout - anything whose load timing or content you donβt control.
networkidledoes not wait for embedded iframe subresources. - Time-derived:
Date.now()clocks, βX minutes agoβ timestamps, today-highlighted calendars, expiry countdowns - Per-session randomness: avatars seeded from session id, A/B-test variants, generated IDs, shuffled lists
- Anti-bot / personalization noise: async cookie banners, recommendation strips, geo-derived prices
Donβt mask real content that just happens to be changing - thatβs the change you want the test to catch.
Default attribute
The agent always masks any element matching [data-blazediff-agent-mask]. No manifest changes are needed. This is the preferred path whenever you can edit the source.
<div data-blazediff-agent-mask>...</div>
// or with a reason inline:
<div data-blazediff-agent-mask="report-carousel">...</div>The attribute value is ignored by the matcher (presence is enough); use it to document intent for future readers. Add the attribute to a shared component (layout, header, footer) and the mask applies on every route automatically.
Per-entry selector (fallback)
When you canβt edit the source (third-party iframe, framework-owned element), fall back to a CSS selector on the manifest entry. Selectors are passed to document.querySelectorAll, then painted with a magenta rect over the bounding rect in both baseline and actual.
- For external embeds, target the element type:
iframe,video,[data-testid="storybook-preview"]. - Avoid Tailwind class chains and
nth-childselectors. They break on the next style tweak. - Scope matters. Each manifest entry has its own
maskarray, soiframeon/docs/ui-components/vanillawonβt affect/home.
Re-capture the affected entries with the new mask list. The mask list replaces the existing one. Include every selector you want kept.
cat <<'EOF' | blazediff-agent capture --stdin --mode baseline --json
[
{"id": "examples-vanilla", "url": "/docs/ui-components/vanilla", "mask": ["iframe"]}
]
EOFRe-run check to confirm the entry now passes.
Configuration
.blazediff/config.json is written by onboard and committed:
{
"devServer": {
"command": "pnpm dev",
"port": 3000,
"readyTimeoutMs": 60000
},
"framework": "next",
"packageManager": "pnpm",
"baseUrl": "http://127.0.0.1:3000"
}Per-route behavior (login, interactions) lives in harnesses, not config β see Harnesses below.
Omit devServer to point the agent at an already-running URL (set baseUrl directly):
blazediff-agent onboard --url https://staging.example.com --json.blazediff/manifest.json is written by capture - never edit it directly. Each entry holds:
{
id: string;
url: string;
mask: string[]; // CSS selectors
viewport: { width: number; height: number };
waitFor: ("networkidle" | "fonts" | string)[];
fullPage: boolean;
harnesses?: { name: string; params?: Record<string, unknown> }[];
parent?: string; // set on sub-entries from screenshot(name)
derived?: boolean;
}Harnesses
A harness is a pluggable script in .blazediff/harnesses/<name>.js, attached
to an entry via its harnesses: [{ name, params? }] list. Login is just one kind
of harness β anything that drives the page before or around a screenshot is the
same concept.
A harness is an ESM module (.js / .mjs β TypeScript is not auto-transpiled)
that default-exports a Harness. Two phases:
setupβ runs before navigation (establish a session, e.g. login).interact(default) β runs after the base screenshot; drives the page and may emit extra named screenshots viascreenshot(name). Each becomes its own baseline entry, id<entry>__<name>.
export interface HarnessContext<P = Record<string, unknown>> {
page: import("playwright").Page;
browser: import("playwright").Browser;
context: import("playwright").BrowserContext;
params: P; // e.g. { persona: "default" }
screenshot(name: string): Promise<void>;
}
export interface Harness<P = Record<string, unknown>> {
phase?: "setup" | "interact";
run(ctx: HarnessContext<P>): Promise<void>;
}Interaction harnesses
For a test that needs the page driven mid-flow (open a menu, switch a tab, then shoot again), write an interact harness and attach it by name:
// .blazediff/harnesses/weather-menu.js
/** @type {import("@blazediff/agent").Harness} */
export default {
async run({ page, screenshot }) {
await page.getByRole("button", { name: "More options" }).click();
await screenshot("menu"); // -> baseline "weather__menu"
},
};{ "id": "weather", "url": "/weather", "harnesses": ["weather-menu"] }The base shot weather fires automatically; every screenshot("menu") becomes
its own manifest/baseline/diff entry. To re-baseline a multi-shot entry,
rewrite <parent-id> re-runs the harness and regenerates all children.
Login harness
Routes behind a login flow capture through a setup harness. Credentials live
in environment variables β never in the harness file, the manifest, or LLM
context (the harness only references process.env.BLAZEDIFF_AUTH_*).
For a plain email/password form the agent writes the harness directly β it
identifies the form fields from the login route source or a DOM snapshot and
emits .blazediff/harnesses/auth.js:
/** @type {import("@blazediff/agent").Harness<{ persona?: string }>} */
export default {
phase: "setup",
async run({ page, params }) {
const upper = (params.persona ?? "default").toUpperCase().replace(/[^A-Z0-9]/g, "_");
const email = process.env[`BLAZEDIFF_AUTH_${upper}_EMAIL`];
const password = process.env[`BLAZEDIFF_AUTH_${upper}_PASSWORD`];
if (!email || !password) throw new Error(`missing BLAZEDIFF_AUTH_${upper}_EMAIL / _PASSWORD`);
await page.goto("http://127.0.0.1:3000/login");
await page.locator('input[name="email"]').fill(email);
await page.locator('input[name="password"]').fill(password);
await Promise.all([
page.waitForURL((u) => !u.pathname.startsWith("/login")),
page.getByRole("button", { name: /sign in|log in/i }).click(),
]);
},
};For flows that canβt be reduced to fill-and-submit β OAuth/SSO, magic links, MFA, captcha β record it interactively instead:
blazediff-agent auth init --persona default --login-url http://127.0.0.1:3000/loginThis opens a Playwright recorder; log in once, and on close the agent swaps the
typed email/password for process.env.BLAZEDIFF_AUTH_<PERSONA>_* and writes the
same .blazediff/harnesses/auth.js.
Per-entry. Add the harness to the entryβs harnesses list:
{ "id": "dashboard", "url": "/dashboard",
"harnesses": [{ "name": "auth", "params": { "persona": "default" } }] }Credentials. The CLI auto-loads env files from --cwd β
.blazediff/.env[.local] (blazediff-scoped, auto-gitignored) then the
project-root .env[.local] β before any harness runs. Real exported env vars
win; .blazediff/ files beat the root. So just drop them in .blazediff/.env:
printf 'BLAZEDIFF_AUTH_DEFAULT_EMAIL=you@example.com\nBLAZEDIFF_AUTH_DEFAULT_PASSWORD=hunter2\n' \
> .blazediff/.env
blazediff-agent checkThe harness throws a clear error at capture time if its vars are missing.
Multiple personas. Use a different params.persona per entry; each maps to
its own BLAZEDIFF_AUTH_<PERSONA>_* pair. One harness file serves them all.
Note. Every harness-gated capture runs in a fresh browser context
(storageState reuse is not yet implemented), so a setup harness re-runs per
entry.
Working reference. examples/agent-auth-spa-example
in the repo is a Vite + React SPA with 2 public and 8 auth-gated routes. It
ships a .blazediff/harnesses/auth.js and committed baselines, so you can
clone the repo and run pnpm --filter @blazediff/agent-auth-spa-example check
to see the full flow pass 10/10.
CI
In CI (CI=1 or no TTY), only check is allowed. onboard / capture / rewrite / reset are explicitly blocked - authoring belongs at the developerβs machine.
GitHub Actions
- run: pnpm install
- run: npx blazediff-agent browsers install
- run: npx blazediff-agent --cwd apps/website check --json
env:
# Only needed if any entry uses a login harness. One pair per persona.
# (In CI, set these as secrets rather than committing .blazediff/.env.)
BLAZEDIFF_AUTH_DEFAULT_EMAIL: ${{ secrets.BLAZEDIFF_AUTH_DEFAULT_EMAIL }}
BLAZEDIFF_AUTH_DEFAULT_PASSWORD: ${{ secrets.BLAZEDIFF_AUTH_DEFAULT_PASSWORD }}Exit codes:
0- every entry passed1- at least one regression, intentional, noise, or pending-judgment entry- non-zero with structured JSON error on infra failures (missing manifest, no chromium, etc.)
Hard rules
- Never
--mode baselinean existing manifest entry without explicit user request. - Never edit
.blazediff/manifest.jsondirectly. - In CI (
CI=1or no TTY), onlycheckis allowed. - A route that times out is logged once in the result array and skipped - never blocks the run.
- Never leave a dev server running after authoring exits.
serve-status --killis mandatory teardown.