earmark v0.1
Visual feedback, for coding agents

Point at it. Your agent finds the code.

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”.

$npm install -D earmark Read the docs Source on GitHub
Revenue
$48,220
+12.4% vs last month
Export 1 Details
What the agent receives
### 1. Export button padding is too tight
 
- Selector: [data-testid="export-btn"]
- Source: index.html:101:11
- CSS rule: styles.css:49 (button)
- Computed: padding: 7px 13px
- Box: 66×37 at (194, 376)

1 One click produces every fact an agent needs to grep, open the file, and make the edit.

§01 Why

Describing a pixel to a language model is the slow part.

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.

Precise

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.

Universal

Any framework, or none

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.

Two-way

The agent can answer

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.

§02 Install

Two lines to mount it.

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()

With no bundler at all

<script type="module" src="/node_modules/earmark/src/index.js" data-earmark-auto></script>

Options

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) => {},
})

TypeScript

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.

§03 Toolbar

Five tools, bottom right.

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

Priority

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.

§04 The loop

Copy-paste, or let the agent read directly.

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.

BROWSER click → annotate pins, panel markdown source resolution BROKER · 127.0.0.1 store + sessions SSE + long-poll json / sqlite webhooks AGENT · MCP list · watch acknowledge · ask resolve · dismiss sessions POST SSE watch reply A human answer wakes the agent’s next watch. Nothing leaves the machine.
The broker binds to loopback only. In copy-paste mode the middle box does not exist.

The fix loop, once MCP is connected

Agent blocks on earmark_watch_annotations : a long poll, not a busy loop. It wakes the moment you save an annotation.
Acknowledges it : the pin turns blue. You can see it has been picked up rather than ignored.
Opens the reported file and line : no grep, no guessing which of four buttons you meant.
Asks, if the feedback is ambiguous : the question appears on the pin; your answer wakes the next watch.
Resolves with a summary : the pin turns green, and the loop starts again.
open acknowledged needs input resolved dismissed
§05 Source

Three ways to find the line, in order of confidence.

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.

Tier 1 · JSX & Svelte

Stamped at build time

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.

Tier 2 · HTML & CSS

Resolved from the served page

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.

Tier 3 · Always

Selector, text, component path

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

// 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.js

// 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.

Iframes and canvas

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)

Plain HTML and CSS

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.

§06 MCP

Eleven tools, one process.

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?

When something is not working, ask it why

$ 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.

§07 Sessions

A session is one tab, not one page load.

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.

  • Client-side navigation is tracked. pushState, replaceState, popstate and hashchange all update the session’s route list.
  • A tab counts as connected for exactly as long as its event stream is open. No heartbeat protocol to get wrong.
  • Reconnecting merges rather than overwrites. If the agent replied while the tab was closed, that reply survives the reconnect.
§08 Broker

Runs standalone, if you would rather not use MCP.

npx earmark-server --port 7331
curl http://127.0.0.1:7331/markdown
Storage

json, sqlite or memory

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.

Webhooks

Push events onward

--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
§09 Safety

A development tool, scoped like one.

  • The broker binds 127.0.0.1 only. Do not bind it to 0.0.0.0.
  • CORS is open by design: your dev server lives on an arbitrary origin. Any page in your browser can reach a loopback port, so pass --token if that matters on your machine.
  • Source resolution re-fetches your own page and stylesheets from the same origin. Nothing is sent anywhere.
  • Webhooks are the exception: they send page URLs, element text and whatever you typed off the machine. Only configure endpoints you control.
  • Do not run it on a shared or public host.
§10 Limits

What it does not do.

Stated plainly, because finding out later is worse than knowing now.

  • Touch works, small screens are not the target. Picking, dragging a region and the whole panel respond to touch, and the controls grow for a fingertip. It is still a tool for looking at your app on a machine you develop on.
  • No screenshots, on purpose. Your agent already drives a browser, and it is handed a verified selector and a URL, so it can capture the element itself at full fidelity. A bundled screenshot library would send a re-render of the page rather than what the browser painted.
  • Cross-origin iframes stay invisible. Same-origin frames are pickable: you get the element, the frame that holds it, and a selector that resolves inside that frame. A cross-origin frame is a browser security boundary, not a gap to work around.
  • Canvas and WebGL have no DOM to annotate. What you get instead is the coordinate space the drawing code works in: the clicked pixel in the canvas buffer, the buffer size against the CSS box, the ratio between them, the context type and the detected renderer.
  • Closed shadow roots are opaque. Open ones are picked into and reported with the expression that reaches them. A root created with mode: 'closed' exposes nothing to any script, so the component itself is what gets annotated.
  • Nothing inside a shadow root or a canvas has a source line. That markup is created by script and its styles never appear in the document's stylesheets, so there is no file to walk.
  • Version 0.1.x. All six packages are on npm and the whole surface is tested, but nothing here has been through a second pair of hands yet.