A list of the things to look at with your own eyes after Claude changes something you can see, each with a link and a tick.

Claude Code tells you what it changed. Check it tells you where to look.
After a long session, Claude says it's done. It changed six things. Which ones can you actually see, and where do you click? Check it keeps the list for you: every change you can see in a browser, with a link, what you should find there, and a box to tick. If something's off, one button tells Claude which one.
For the person checking the result, not the diff.
Build checks · Changelog · MIT license

Claude Code 2.1.288, right after Claude changed the Saturday hours and the menu of a made-up bakery site from demo/.
Needs Claude Code 2.1.288 or later (claude --version shows yours).
claude plugin marketplace add davideiffert/check-it && claude plugin install check-it@check-it --config siteAddress=http://localhost:3000
Swap http://localhost:3000 for wherever your app or site runs right now, on this computer or online. The command only installs Check it. It doesn't start your app for you. Then start Claude Code as usual.
open list and ask what to check buttons. It stays hidden until the first item arrives.open link opens the page in your browser.checked ticks it. Ticked items fold into one checked (N) row, where you can undo a tick.not right puts Not right: <item> (<address>). at the end of your prompt, after anything you already typed. Add a sentence and send it. It never sends anything by itself.dismiss removes the item./check-it opens the list. /check-it clear empties it.ask what to check puts a request at the end of your prompt asking Claude what you should look at. You decide whether to send it.Claude fills it. The plugin gives Claude one tool for adding an item and one short instruction: when a change alters what someone sees or does on a page, add one item per change, with the address where it shows right now. Changes nobody can see (tidying code, tests, settings) get no items. Something every page shares, like the footer, gets one item.
New items wait until Claude's turn ends, so nothing shows up while Claude is still halfway through the change. That's a delay, not a check: the list doesn't confirm the change exists. If you stop a turn, its items are dropped. Only the main conversation adds items. Helper agents Claude starts cannot.
If Claude can't name an address where a change shows (nothing is running, or it isn't built yet), it adds nothing and says so in its reply, naming what it needs.
The list is Claude's word for it. The tick is yours.
The list is kept per project and survives restarts. It holds 50 items, and the oldest ticked ones go first.
Measured on two demo sites, a plain one and one with a build step: 51 recorded sessions (31 and 20) using Claude Opus and Sonnet, plus 3 interactive ones.
127.0.0.1; since 0.1.2 that opens as a link.Details are in docs/measurement-v0.md.
siteAddress setting, the one setting worth filling in. Without it, and with nothing running, Claude often has no address to give you and the list stays empty. Set it with --config as above, or later in /config under check-it./tui fullscreen the list docks beside the conversation. On the normal screen it opens above the prompt instead./theme.open link is a plain link, and it only works for https: and http://localhost addresses. Other addresses show as text.Almost nothing outside its own list. No network access, no model calls of its own, no files. It runs one command, and only when you press open link in the terminal: your system's own opener (open on macOS, xdg-open on Linux), with an http:// or https:// address the plugin has already checked, to show the page in your browser. It adds one tool, one short section of Claude's instructions, the line above the prompt, the list and the /check-it command.
From the marketplace, as above. To try a local copy without installing:
git clone https://github.com/davideiffert/check-it
claude --plugin-dir ./check-it
Issues and pull requests are welcome. Before sending a change, run:
claude plugin validate .
claude plugin test .
For the type check, start Claude Code once with --plugin-dir . (it writes the type declarations into .claude-plugin/types), then run npx -p typescript@5 tsc -p ..
MIT. Made by David Eiffert.
hooks/register.tsx 284 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Held, Item, Project, Shown } from '../types'
5import {
6 ASK_TEXT,
7 PLACE_WORDS,
8 add,
9 clear,
10 dismiss,
11 empty,
12 leftLine,
13 notRightText,
14 safeHref,
15 setStatus,
16 storeKey,
17 toItem,
18 openableHref,
19} from './list'
20
21const TOOL = 'mcp__check-it__add'
22const PANE = 'check-it'
23
24const shown = atom({ plugin: 'check-it', key: 'shown' } as const, { root: '', ...empty() } as Shown)
25const showChecked = atom({ plugin: 'check-it', key: 'showChecked' } as const, false)
26const pending = atom({ plugin: 'check-it', key: 'pending' } as const, [] as Held[])
27
28export const DESCRIPTION = [
29 "Adds one item to the person's Check it list: something they should open and look at with their own eyes",
30 'because you changed what a web page shows or does. Call it once per visible change, only after the edit is saved',
31 'and the change can be seen at `where`, never before you make it. Do not call it for changes nobody can see (refactors, tests, config, scripts,',
32 'comments). Reuse the same `id` when the same thing changes again.',
33].join(' ')
34
35const INPUT_SCHEMA = {
36 type: 'object',
37 properties: {
38 id: {
39 type: 'string',
40 description: 'A short stable id for this thing, e.g. "contact-hours". Reuse it when the same thing changes again.',
41 },
42 label: {
43 type: 'string',
44 description: 'What to look at, in plain words a non-developer uses, no file names. e.g. "Opening hours on the contact page".',
45 },
46 expect: {
47 type: 'string',
48 description: 'One line: what the person should see there now. e.g. "Saturday now says 9 to 1".',
49 },
50 where: {
51 type: 'string',
52 description: 'The full web address where the change can be seen now, starting with http:// or https://, with the page path. For a page on this computer write localhost, not 127.0.0.1.',
53 },
54 place: {
55 type: 'string',
56 enum: ['local', 'preview', 'live'],
57 description: 'local: on this computer only. preview: a test version online. live: the public site.',
58 },
59 },
60 required: ['id', 'label', 'expect', 'where', 'place'],
61}
62
63export const sectionText = (siteAddress: string) =>
64 [
65 '# Check it list',
66 'The person keeps a list of things to look at after you change something they can see. When your work changes',
67 'what a web page shows or how it behaves for a visitor (words, layout, colors, images, links, buttons, forms,',
68 `new or removed pages), call ${TOOL} once for each distinct visible change, after your edits are saved and before`,
69 'your final reply (never before making the change), with the',
70 'address of the page where that change can be seen right now. Several changes on one page that a person would',
71 'check at a glance can be one item; changes on different pages are separate items. A change to something every',
72 'page shares (the top bar, the footer, the colors) is one item, linked to one page that shows it.',
73 'Do not add items for changes with no visible effect. If a change cannot be seen yet (not built, not deployed,',
74 'nothing serving it), do not add it. When you made a visible change but cannot name an address where it can be',
75 'seen right now, say so in one sentence in your reply and name what you need: the site address (the person sets',
76 'it in /config under check-it) or how the site is served.',
77 ...(siteAddress === '' ? [] : [`The site can be seen at ${siteAddress}. Build links from it.`]),
78 ].join('\n')
79
80type $ = EngineInterface
81
82const stored = async ($: $, root: string): Promise<Project> =>
83 ((await $.store.get(storeKey(root))) as Project | undefined) ?? empty()
84
85/**
86 * Runs list writes one at a time, so two presses (or a press and a turn ending)
87 * never read the same list and overwrite each other.
88 */
89let queue: Promise<unknown> = Promise.resolve()
90const serial = <T,>(work: () => Promise<T>): Promise<T> => {
91 const run = queue.then(work, work)
92 queue = run.catch(() => undefined)
93 return run
94}
95
96/** Changes the list of one project, and the screen when it shows that project. */
97const change = ($: $, root: string, fn: (p: Project) => Project) =>
98 serial(async () => {
99 const next = fn(await stored($, root))
100 await $.store.set(storeKey(root), next)
101 await update($, shown, now => (now.root === root || now.root === '' ? { root, ...next } : now))
102 return next
103 })
104
105/** Puts the current project's list on screen. */
106const show = ($: $) =>
107 serial(async () => {
108 const root = await $.session.root()
109 const next = await stored($, root)
110 await update($, shown, () => ({ root, ...next }))
111 })
112
113/** Adds text after whatever the person has typed, with a space between, and never sends it. */
114const appendToPrompt = async ($: $, text: string) => {
115 const draft = (await $.prompt.read()).text
116 const gap = draft === '' || /\s$/.test(draft) ? '' : ' '
117 return $.prompt.fill({ text: gap + text, mode: 'append' })
118}
119
120/** Opened by the person (a command or a press), so it takes the keys: Tab walks its buttons, Esc closes it. */
121// The terminal draws a Link as a hyperlink with no hover or focus, so there the mod opens the
122// page itself, with the system's own opener and an address safeHref already passed. No shell.
123const openInBrowser = async ($: $, href: string) => {
124 for (const opener of ['open', 'xdg-open']) {
125 try {
126 const ran = await $.process.run([opener, href], { timeoutMs: 10000 })
127 if (ran.exitCode === 0) return
128 } catch {
129 // this opener is not on this computer; try the next
130 }
131 }
132 $.ui.toast(`Could not open the browser. The page is at ${href}`)
133}
134
135const openPane = ($: $) => show($).then(() => openOnly($))
136const openOnly = ($: $) => $.ui.open({ id: PANE, title: 'Check it', columns: 34, focus: true, closeOnEscape: true })
137
138export const register: Register = (on, options) => {
139 const siteAddress = typeof options.siteAddress === 'string' ? options.siteAddress.trim() : ''
140
141 on('session.start', async ($, e, next) => {
142 await $.tool.register({ name: 'add', description: DESCRIPTION, inputSchema: INPUT_SCHEMA })
143 await $.command.register({
144 name: 'check-it',
145 description: 'Open the Check it list (/check-it clear empties it)',
146 })
147 await show($)
148 return next(e)
149 })
150
151 on('tool.describe', { tool: TOOL }, async ($, e, next) => ({ ...(await next(e)), isDeferred: false }))
152
153 on('tool.check', { tool: TOOL }, () => ({ decision: 'allow' }))
154
155 on('tool.call', { tool: TOOL }, async ($, e) => {
156 // Only the main conversation adds items: a subagent's turn can be stopped or
157 // still running when the main turn ends, and its items would show anyway.
158 if (e.agentId !== undefined) {
159 return { result: 'Not added: only the main conversation can add to the Check it list. Say in your answer what the person should look at.' }
160 }
161 const item = toItem(e as Record<string, unknown>, await $.clock.now())
162 if ('error' in item) return { result: `Not added: ${item.error}` }
163 // Held until the turn ends, and kept with the project it was added in.
164 const root = await $.session.root()
165 await update($, pending, held => [...held.filter(one => one.root !== root || one.item.id !== item.id), { root, item }])
166 const note = openableHref(item.where) === undefined
167 ? ' Note: that is not an http:// or https:// address, so it shows as plain text.'
168 : ''
169 return { result: `Added "${item.label}" to the Check it list.${note}` }
170 })
171
172 on('turn.complete', async ($, e, next) => {
173 if (e.agentId === undefined) {
174 const held = await read($, pending)
175 if (held.length > 0) {
176 await update($, pending, () => [])
177 if (e.reason !== 'aborted' && !e.isAborted) {
178 for (const root of new Set(held.map(one => one.root))) {
179 const items = held.filter(one => one.root === root).map(one => one.item)
180 await change($, root, p => items.reduce(add, p))
181 }
182 }
183 }
184 }
185 return next(e)
186 })
187
188 on('prompt.compose', async ($, e, next) => {
189 const composed = await next(e)
190 return {
191 sections: [...composed.sections, { id: 'check-it:guide', text: sectionText(siteAddress), scope: 'session' }],
192 }
193 })
194
195 on('command.run', { command: 'check-it' }, async ($, e) => {
196 if (e.args.trim() === 'clear') {
197 const root = await $.session.root()
198 await change($, root, clear)
199 await update($, pending, held => held.filter(one => one.root !== root))
200 return { text: 'Check it list cleared.' }
201 }
202 await openPane($)
203 return { text: 'Check it list opened.' }
204 })
205
206 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207 const p = await read($, shown)
208 if (e.props.hasSurvey || !p.ever) return next(e)
209 const { Box, Button, Text } = $.ui.resolve(e)
210 return (
211 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
212 <Text>{leftLine(p)}</Text>
213 <Button key="open" label="open list" onPress={() => openPane($)} />
214 <Button key="ask" label="ask what to check" onPress={() => appendToPrompt($, ASK_TEXT)} />
215 </Box>
216 )
217 })
218
219 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220 const { Box, Button, Link, Text } = $.ui.resolve(e)
221 const p = await read($, shown)
222 // Every press acts on the project this list belongs to, even after a change of directory.
223 const root = p.root
224 const isShowingChecked = await read($, showChecked)
225 const open = p.items.filter(one => one.status === 'open').reverse()
226 const checked = p.items.filter(one => one.status === 'checked').reverse()
227
228 const whereOf = (item: Item) => {
229 // The terminal button opens any http(s) page; a Link elsewhere takes only what safeHref passes.
230 const href = e.surface === 'terminal' ? openableHref(item.where) : safeHref(item.where)
231 if (href === undefined) return <Text dimColor wrap="truncate-end">{item.where}</Text>
232 return e.surface === 'terminal'
233 ? <Button key={`open:${item.id}`} label="open link" onPress={() => openInBrowser($, href)} />
234 : <Link href={href} label="open link" />
235 }
236
237 return (
238 <Box flexDirection="column" rowGap={1}>
239 <Text dimColor>
240 {open.length === 0 ? 'Nothing left to look at. New items appear when Claude changes a page you can see.' : `${open.length} to look at. Claude says:`}
241 </Text>
242 {open.map(item => (
243 <Box key={`item:${item.id}`} flexDirection="column">
244 <Text bold>{item.label}</Text>
245 {item.expect !== '' && <Text>{item.expect}</Text>}
246 <Box flexDirection="row" columnGap={1}>
247 <Text dimColor>{PLACE_WORDS[item.place]}</Text>
248 {whereOf(item)}
249 </Box>
250 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
251 <Button key={`check:${item.id}`} label="checked" onPress={() => change($, root, q => setStatus(q, item.id, 'checked'))} />
252 <Button
253 key={`bad:${item.id}`}
254 label="not right"
255 onPress={() => appendToPrompt($, notRightText(item))}
256 />
257 <Button key={`dismiss:${item.id}`} label="dismiss" dimColor onPress={() => change($, root, q => dismiss(q, item.id))} />
258 </Box>
259 </Box>
260 ))}
261 {checked.length > 0 && (
262 <Box key="checked" flexDirection="column">
263 <Button
264 key="toggle-checked"
265 plain
266 dimColor
267 label={`${isShowingChecked ? 'v' : '>'} checked (${checked.length})`}
268 onPress={() => update($, showChecked, shown => !shown)}
269 />
270 {isShowingChecked &&
271 checked.map(item => (
272 <Box key={`done:${item.id}`} flexDirection="row" columnGap={1}>
273 <Text dimColor wrap="truncate-end">{item.label}</Text>
274 <Button key={`undo:${item.id}`} label="undo" dimColor onPress={() => change($, root, q => setStatus(q, item.id, 'open'))} />
275 </Box>
276 ))}
277 </Box>
278 )}
279 <Button key="ask" label="ask what to check" dimColor onPress={() => appendToPrompt($, ASK_TEXT)} />
280 </Box>
281 )
282 })
283}
284hooks/list.ts 121 lines1import type { Item, Place, Project } from '../types'
2
3export const CAP = 50
4export const MAX_WHERE = 2048
5export const PLACES: readonly Place[] = ['local', 'preview', 'live']
6
7export const empty = (): Project => ({ items: [], ever: false })
8
9export const PLACE_WORDS: Record<Place, string> = {
10 local: 'on this computer',
11 preview: 'test version',
12 live: 'live',
13}
14
15const LOOPBACK = ['127.0.0.1', '[::1]', '0.0.0.0']
16
17export const storeKey = (root: string) => `project:${root}`
18
19/** The href a Link may take, or undefined when `where` must show as text. */
20export const safeHref = (where: string): string | undefined => {
21 let url: URL
22 try {
23 url = new URL(where.trim())
24 } catch {
25 return undefined
26 }
27 // A loopback address is this computer: open it as localhost, the one http: host a Link takes.
28 if (url.protocol === 'http:' && LOOPBACK.includes(url.hostname)) url.hostname = 'localhost'
29 const isLocal = url.protocol === 'http:' && url.hostname === 'localhost'
30 if (url.protocol !== 'https:' && !isLocal) return undefined
31 if (url.username !== '' || url.password !== '') return undefined
32 const href = url.href
33 if (href.length > 2048 || href.includes('@') || !/^[\x21-\x7e]+$/.test(href)) return undefined
34 return href
35}
36
37/**
38 * The address the terminal's `open link` button may hand to the system opener, or undefined.
39 * Wider than a Link takes: any http: or https: page, so a dev server on another machine, a
40 * local network address or a .test domain opens too.
41 */
42export const openableHref = (where: string): string | undefined => {
43 const safe = safeHref(where)
44 if (safe !== undefined) return safe
45 let url: URL
46 try {
47 url = new URL(where.trim())
48 } catch {
49 return undefined
50 }
51 if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
52 if (url.username !== '' || url.password !== '') return undefined
53 const href = url.href
54 if (href.length > 2048 || href.includes('@') || !/^[\x21-\x7e]+$/.test(href)) return undefined
55 return href
56}
57
58export type AddInput = {
59 id?: unknown
60 label?: unknown
61 expect?: unknown
62 where?: unknown
63 place?: unknown
64}
65
66const text = (value: unknown, max: number) =>
67 typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : ''
68
69const slug = (value: string) =>
70 value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48)
71
72/** Turns the model's tool input into an item, or a reason it cannot be one. */
73export const toItem = (input: AddInput, now: number): Item | { error: string } => {
74 const label = text(input.label, 80)
75 // An address is never shortened or reworded: a cut URL can lead somewhere else.
76 const where = typeof input.where === 'string' ? input.where.trim() : ''
77 if (label === '') return { error: 'label is required.' }
78 if (where === '') return { error: 'where is required: the web address where the change can be seen.' }
79 if (where.length > MAX_WHERE) return { error: `where is longer than ${MAX_WHERE} characters. Give the page's address without extra query text.` }
80 const id = slug(text(input.id, 64)) || slug(label) || 'item'
81 const place = PLACES.find(one => one === input.place) ?? (safeHref(where)?.startsWith('http://localhost') ? 'local' : 'live')
82 return { id, label, expect: text(input.expect, 160), where, place, addedAt: now, status: 'open' }
83}
84
85/** Adds an item, replacing one with the same id (the thing changed again). */
86export const add = (project: Project, item: Item): Project =>
87 cap({ items: [...project.items.filter(one => one.id !== item.id), item], ever: true })
88
89/** Keeps at most CAP items: oldest checked go first, then oldest open. */
90export const cap = (project: Project): Project => {
91 const items = [...project.items]
92 while (items.length > CAP) {
93 const checked = items.findIndex(one => one.status === 'checked')
94 items.splice(checked === -1 ? 0 : checked, 1)
95 }
96 return { ...project, items }
97}
98
99export const setStatus = (project: Project, id: string, status: Item['status']): Project => ({
100 ...project,
101 items: project.items.map(one => (one.id === id ? { ...one, status } : one)),
102})
103
104export const dismiss = (project: Project, id: string): Project => ({
105 ...project,
106 items: project.items.filter(one => one.id !== id),
107})
108
109export const clear = (project: Project): Project => ({ ...project, items: [] })
110
111export const notRightText = (item: Item) => `Not right: ${item.label} (${item.where}). `
112
113export const ASK_TEXT =
114 'What should I look at to check the changes you made? Add each one to my Check it list. '
115
116export const leftLine = (project: Project) => {
117 const left = project.items.filter(one => one.status === 'open').length
118 if (left === 0) return 'Check it: nothing left to look at'
119 return `Check it: ${left} left to look at`
120}
121types/index.d.ts 26 lines1export type Place = 'local' | 'preview' | 'live'
2
3export type Item = {
4 id: string
5 label: string
6 expect: string
7 where: string
8 place: Place
9 addedAt: number
10 status: 'open' | 'checked'
11}
12
13export type Project = { items: Item[]; ever: boolean }
14
15/** The list on screen and the project root it belongs to. */
16export type Shown = Project & { root: string }
17
18/** An item Claude added this turn, and the project root it was added in. */
19export type Held = { root: string; item: Item }
20
21declare module 'claude-code' {
22 interface PluginState {
23 'check-it': { shown: Shown; showChecked: boolean; pending: Held[] }
24 }
25}
26