Verified selectors
Test ids first, then ids, then semantic attributes, then a scoped path. Build-hashed class names are filtered out, because a selector containing a hash is worse than useless.
Click an element in your running app and say what should change. earmark hands your agent the selector, the source file and line, the component path, the computed styles and the exact box, instead of “the button on the right looks wrong”.
1 One click produces every fact an agent needs to grep, open the file, and make the edit.
You can see the problem instantly. Communicating it costs three paragraphs, two screenshots and a round of “which button?”. Your agent then greps for a class name that turned out to be generated at build time.
earmark removes the translation step. You point; it captures the things a machine can act on. Everything it reports is verified before it is reported: a selector is checked against the document until it resolves to exactly one element, and a source line is checked against the live DOM before it is claimed.
Test ids first, then ids, then semantic attributes, then a scoped path. Build-hashed class names are filtered out, because a selector containing a hash is worse than useless.
Plain DOM and Shadow DOM, zero runtime dependencies. Component paths for React, Vue, Angular and Svelte are an enhancement layer, never a requirement. Picking reaches into same-origin iframes.
It can ask you a clarifying question, mark work as picked up, or resolve an annotation with a summary. The pin changes colour in your browser.
earmark ships as plain ESM with no build step of its own. Mount it behind a development guard so it never reaches production.
// main.js: anywhere that runs once, in development import { createEarmark } from 'earmark' if (import.meta.env.DEV) createEarmark()
<script type="module" src="/node_modules/earmark/src/index.js" data-earmark-auto></script>
createEarmark({
endpoint: 'http://127.0.0.1:7331', // or false for copy-paste only
hotkey: 'alt+a',
theme: 'auto', // 'auto' | 'light' | 'dark'
persist: true, // keep annotations across reloads
onAnnotate: (annotation) => {},
})
Every package ships hand-written declarations. The domain types live
in earmark and are re-exported by the broker and the MCP
server, so an annotation is the same type on both sides of the wire.
import { createEarmark, type Annotation } from 'earmark' const pending: Annotation[] = createEarmark().annotations
The endpoint defaults to the local broker and fails quietly when nothing is listening. The overlay stays fully usable in copy-paste mode; the sync dot simply goes grey.
Press alt+a or click the arrow to start picking.
⌘↵ saves an annotation, esc cancels.
| Tool | What it does | Use it for |
|---|---|---|
| Pick | Hover highlights an element and names it; click to annotate. Shift-click accumulates several, then click to finish. | Almost everything |
| Text | Select running text. The exact string is captured alongside its container. | Typos, copy changes, tone |
| Region | Drag a box. Reports every element substantially inside it. Over a canvas, where there is nothing to select, it reports the region in the canvas's own buffer pixels. | Spacing, gaps, canvas |
| Freeze |
Pauses CSS animations, element.animate()
timelines, and any playing video or
audio, including inside same-origin iframes.
|
Spinners, carousels, preview panes |
| Panel | Review, delete, answer the agent, copy markdown for everything at once. | Before handing work over |
Each annotation is high, normal or low. High sorts first when the agent lists them, so an agent working top-down hits the urgent items before the nits.
Copy-paste mode needs no infrastructure at all: click Copy markdown and paste into your agent. Agent-sync mode runs a local broker so the agent reads annotations itself, asks questions, and reports back.
earmark_watch_annotations
: a long poll, not a busy loop. It wakes the moment you save an annotation.
A selector tells an agent what to grep for. A file and line tells it where to look: the difference between one edit and three searches. earmark tries each tier and reports which one produced the answer.
Vite, Next.js and Svelte all write
data-earmark-src onto every element you actually
wrote. Exact, and it works on React 19, which removed the
runtime _debugSource field older tools rely on.
Svelte files carry their component name too, which is the only
way a Svelte annotation can have a component chain.
No build step? The document is re-fetched and parsed with position tracking, then the element’s path is walked in the source. Every step is checked against the live tag name, so a framework shell reports nothing rather than a wrong line.
Even with no source at all, the agent gets a verified unique selector, the exact visible text, the component chain, the computed styles, and the frame or canvas coordinate space the element sits in.
// vite.config.js import earmark from 'vite-plugin-earmark' export default { plugins: [react(), earmark()] }
Covers .jsx, .tsx and
.svelte. The Svelte markup is stamped before the
compiler sees it, so SvelteKit needs nothing extra.
// next.config.mjs import { withEarmark } from 'earmark-loader/next' export default withEarmark({})
A pre-loader for webpack and Turbopack rather than a Babel plugin, because adding Babel would switch the project off SWC and slow every build down. Both compilations are stamped, client and server: React hydration will not add an attribute the server HTML did not have.
A same-origin iframe is picked like anything else: the annotation names the frame, its document, and a selector that resolves inside it. A canvas has no DOM, so rather than pretend, it reports the coordinate space its drawing code works in.
- **Inside iframe:** `#preview` (preview) - frame document: /examples/frames/child.html - **Canvas:** - buffer 640×360, CSS 320×180 (2× / 2× per CSS pixel, dpr 2) - clicked at buffer pixel (354, 200)
Nothing to configure. Every CSS rule that actually matches the
element is mapped back to the file and line that declares it,
including rules inside a <style> block, which are
offset into their host document rather than reported against a
phantom file.
- **Source:** `index.html:101:11` _(resolved from the served HTML)_ - **CSS rules that style it:** - `button` → index.html (inline <style>):49 - padding: 7px 13px; border-radius: 8px; - `button.primary` → index.html (inline <style>):59 - background: var(--accent); color: rgb(255, 255, 255);
The agent now knows the padding it has to change lives in the
generic button rule at line 49, not in
.primary. That distinction is usually the whole bug.
The MCP server runs the annotation store, the endpoint your browser talks to, and the stdio transport in a single process. There is no second daemon to keep alive.
claude mcp add earmark -- npx -y earmark-mcp
# or write it into the project's .mcp.json
npx earmark-mcp init
| Tool | Purpose |
|---|---|
earmark_list_annotations |
Outstanding work as markdown. Filter by status, session or priority; high sorts first. |
earmark_watch_annotations |
Blocks until you annotate something. This is what makes a fix loop possible. |
earmark_get_annotation |
One annotation with its full reply thread. |
earmark_list_sessions |
Which browser tabs are open and which routes were annotated in each. |
earmark_get_session |
One tab with everything it produced. |
earmark_acknowledge |
“Read it, working on it.” The pin turns blue. |
earmark_ask |
Ask a clarifying question instead of guessing. The pin turns amber. |
earmark_resolve |
Done, with a summary of the change. The pin turns green. |
earmark_dismiss |
Declined, with a reason you can see. |
earmark_clear |
Delete everything. |
earmark_status |
Is the overlay connected? Which endpoint should it use? |
$ npx earmark-mcp doctor ✓ Node version: v24.12.0 ✓ sqlite backend: available ✓ MCP registration: earmark is registered in .mcp.json ✓ Broker: responding on http://127.0.0.1:7331 (1 annotations, 2 sessions) ✓ Browser overlay: http://localhost:5173/ (1 annotations) Everything checks out.
Each failing check prints the command that fixes it, and the exit code is non-zero, so it works in CI.
The session id lives in sessionStorage, so refreshing
does not fragment your feedback into three sessions. Each annotation
still carries its own URL: a tab that wandered across
/dashboard, /settings and
/billing gives the agent one group with three
differently-routed items.
pushState,
replaceState, popstate and
hashchange all update the session’s route list.
npx earmark-server --port 7331 curl http://127.0.0.1:7331/markdown
json writes a readable file on a short debounce.
sqlite writes each change immediately through
node:sqlite: a real database for zero
dependencies, falling back to json where unavailable.
--webhook URL, or
EARMARK_WEBHOOKS. Delivery is fire-and-forget with
a timeout and one retry, so a dead endpoint can never stall the
annotation loop.
| Route | Does |
|---|---|
GET /health |
Liveness, counts, cursor |
GET /annotations |
List; filter by status and session |
POST /annotations |
Create, in batches |
GET /annotations/wait |
Long poll: the primitive behind watch |
PATCH /annotations/:id |
Change status |
POST /annotations/:id/replies |
Append to the thread |
GET /sessions |
Tabs, with counts and routes |
GET /events |
Server-sent events; also a tab’s liveness signal |
GET /markdown |
The agent-facing document, ready to paste |
127.0.0.1 only. Do not bind it to
0.0.0.0.
--token if that matters on your machine.
Stated plainly, because finding out later is worse than knowing now.
mode: 'closed' exposes nothing to any
script, so the component itself is what gets annotated.