Screenshots, checks and PR evidence for coding agents doing UI work.
Coding agents change UI blind: they edit CSS, call the job done, and you find the broken phone layout in review. shot is a CLI that lets the agent see what it changed. It takes before and after stills of your app, runs layout and accessibility checks, records click-through flows, and puts the screenshots it picks in the pull request.
git stash -u && shot /settings --as admin --size phone --size desktop --check --label before
git stash pop && shot /settings --as admin --size phone --size desktop --check --label after
shot publish before after --note "Profile=Save button no longer wraps on phones".shots/a1b2/after/route-settings--profile-admin-iphone-15-light-4c1e9a.png
.shots/a1b2/after/route-settings--profile-admin-1440x900-light-9f03d2.png
error target Profile (iPhone 15): touch target 80×20, under 24×24 (#save)
1 warning (--warnings lists them); 3 findings also in before
- The agent checks its own work. It reads the PNGs it took before it says "done", so you get fewer rounds of "the button is off by 4px".
- Before and after line up. The same capture always gets the same file name, so
before/andafter/pair up without any setup. Shot doesn't run git. The agent stashes its change and shoots both states. - Every size in one run.
--size phone --size desktopshoots each size. Each size uses its own section list, so a sidebar that's hidden on phones isn't shot there. - Bugs you'd catch in review, caught earlier.
--checkfinds overflowing or clipped text, touch targets that are too small, missing focus rings, edges that are 1–3px out of line, and axe violations. - You pick between variants.
shot reviewputs the labels the agent names side by side in a local page. You vote, draw on the stills like in Figma, and send it; the agent gets your picks, numbered notes with the element under each, and the drawings. - Reviewers see what changed, and nothing else.
shot publish before afterputs the sheets the agent picks into the PR description, each with the agent's note: one image per capture, with a row per label and a column per size and mode, so before and after in light and dark is one image. Scratch shots, findings and flows stay local. - Logged-in pages work. You give it a login hook once, and it keeps a session for each user. Requests that would change data (POST/PUT/PATCH/DELETE) are blocked, so a scene can't change your dev data.
- It works with any framework. It only needs a URL. Shot drives Playwright Chromium against your running dev server.
npm i -D agent-shot
npx playwright install chromiumFor the axe accessibility check, also add npm i -D @agent-shot/axe.
You need Node ≥ 24. shot publish needs gh ≥ 2.99. Videos are encoded with a bundled ffmpeg.
Give your agent the instructions. shot skill prints a skill file you can save to your agent's skills directory:
mkdir -p .claude/skills/shot && npx shot skill > .claude/skills/shot/SKILL.mdURLs and local HTML files work without any config: shot https://example.com, shot ./compare.html. To shoot your own app as a logged-in user, add a shot.config.ts at the project root:
import { axe } from '@agent-shot/axe'
import { defineConfig, github } from 'agent-shot'
export default defineConfig({
app: {
url: () => 'http://localhost:3000',
health: '/health',
users: { admin: 'admin@example.org', parent: 'parent@example.org' },
logIn: async ({ page, url, user }) => {
await page.goto(`${url}/login`)
await page.fill('#email', user)
await page.click('button[type=submit]')
},
},
plugins: [axe(), github()],
})--as admin logs in once and reuses the session after that. Optional hooks: isLoginPage, waitUntilReady, applyTheme, writes: 'allow'. You can also set determinism to fix the clock and random seed.
checks sets how loud each check is: 'off' for all of them, or per check (core or a plugin's), typed from your plugins:
checks: { align: 'off', target: 'error', a11y: 'warn' } // 'off' | 'warn' | 'error''off' skips the check; 'warn' and 'error' set the severity of all its findings. A page that fails to render always fails.
# every section of a page, as an admin
shot /app/persons --as admin
# list the section titles, then shoot one in light and dark
shot outline /app/persons --as admin
shot /app/persons --as admin --section "People" --mode both
# one element on a phone and a desktop, with box and gap overlays
shot /settings --as admin --select "#save" --size phone --size desktop --overlay boxes --overlay gaps
# a state: fill the form, press Enter, hover the button, then check it
shot /settings --as admin --fill '#name=Ada' --key Enter --hover "#save" --check
# 4× zoom on an icon, or its computed box, spacing, radius and font as JSON
shot /settings --as admin --select ".icon" --loupe 4
shot /settings --as admin --select "#save" --measure --style color
# an animation as 8 frames, 50ms apart
shot /app --as admin --click "#menu" --frames 8 --every 50
# the accessibility tree of a part
shot /settings --as admin --section "Profile" --ariaRun shot --help to see every flag.
A flow is a Playwright script with named steps. Shot records a still for each step, an H.264 video that fits GitHub's upload limit, and a Playwright trace.
// shots/invite-parent.flow.ts
import { defineFlow } from 'agent-shot'
export default defineFlow({ start: '/app/persons', as: 'admin' }, async ({ page, step }) => {
await step('open invite', () => page.getByRole('button', { name: 'Invite' }).click())
await step('fill email', () => page.getByLabel('Email').fill('parent@example.org'))
await step('submit', () => page.getByRole('button', { name: 'Send' }).click())
})shot flow invite-parent --size phone --label afterThe agent built three variants and wants you to choose. It shoots each as a label, then puts them up for review:
shot "/app/persons?variant=quiet" --as admin --label quiet --section People --size phone --size desktop
# … the same for editorial and dense, and the current state as --label before
shot review quiet editorial dense --base before --ask "Pick a density for People" \
--describe quiet="Lowest weight; least memorable"Your browser opens a canvas with the labels side by side at true width, one sheet per section and a tab per size and mode. Pick one per sheet, or "none of these". Draw with comment pins (C), boxes (R), arrows (A) and pen (P); pan with space-drag or scroll, zoom with ⌘-scroll, ⇧1 fits and ⇧0 is 100%. Keys 1–9 flip between labels at full size. Send it with ⌘↵.
The command blocks until you send or close the page, then prints the feedback for the agent:
# .shots/a1b2/review/round-1/feedback.md
# Review, round 1. Pick: dense
## /app/persons as admin@example.org › People
Picked: dense (over quiet, editorial).
1. dense · iPhone 15, light · pin (75, 203) · `#invite` button "Invite"
> too tight against the list
Images:
- .shots/a1b2/review/round-1/route-app-persons--people-admin-37f5f6--dense--iphone-15-light.png
Each image is the still with your drawings burned in. Only the labels the agent names appear; --sheet and --size narrow it further. Your draft survives a reload, and the next round shows the last one's drawings faded. Exit 0 when you sent something, 1 when you closed it empty. It is local only and never touches the PR.
- stdout: one path per file, one line per error finding (
error, check, part, message), then a note line: how many warnings (--warningslists them) and how many findingsbeforehas too. With--checkon both states, the after run shows only what the change added. Add--jsonfor every finding. Progress goes to stderr only on a terminal, so agents read only what they act on. - Exit codes: 0 when everything is ok. 1 on a render failure (500, redirect, timeout, failed step) or an error finding
beforedoes not have. 2 on a usage error. - Files:
.shots/<session>/[<label>/]<source>--<slug>-<hash>.png. A rerun overwrites the file. Review rounds go to.shots/<session>/review/.shot cleanremoves the session.
A plugin is an object in shot.config.ts: checks run on each still's part with --check, publishers put evidence somewhere with shot publish. Plain values, Promises and Effects all work; a throw becomes an error finding (checks) or a PublishFailedError (publishers).
import { defineConfig, definePlugin, github, isStill } from 'agent-shot'
const todos = definePlugin({
name: 'todos',
checks: [
{
check: 'todo',
appliesTo: isStill,
runCheck: async ({ roots }) => {
const texts = await Promise.all(roots.map((root) => root.innerText()))
return texts.some((text) => text.includes('TODO'))
? [{ severity: 'warn', message: 'TODO left in the page' }]
: []
},
},
],
})
export default defineConfig({ plugins: [github(), todos] })A finding is { severity: 'error' | 'warn', message, selector?, rect? }; rect is in page pixels. A publisher is { publisher: 'slack', publishEvidence: async ({ report, pr }) => ({ url, attachments }) }. Plugins written in Effect add a layer that provides what their checks and publishers need; see src/github.
packages/agent-shot the CLI, capture domain, Playwright adapters, in-page runtime, GitHub publisher
plugins/axe the axe-core check (@agent-shot/axe)
test/fixture-app an app with a login, sections, a 500, a redirect and a PATCH; the adapter tests run against it
docs/spec.md the spec
pnpm install
pnpm check # tsc
pnpm test # vitest, including the fixture app in Chromium
pnpm lintNode ≥ 24 runs the TypeScript sources directly. Set $SHOT_FFMPEG to use your own ffmpeg.