Two lines to mount the overlay.
earmark ships as plain ESM with no build step of its own. Mount it behind a development guard so it never reaches production.
npm install -D earmark
// main.js, anywhere that runs once import { createEarmark } from 'earmark' if (import.meta.env.DEV) createEarmark()
With no bundler at all
The overlay has zero runtime dependencies, so a script tag is a complete installation.
<script type="module" src="/node_modules/earmark/src/index.js" data-earmark-auto></script>
data-earmark-auto mounts it immediately. Pass
data-endpoint, data-hotkey or
data-theme on the same tag to configure it.
Serving a plain project
The script tag path needs the page served from the project root, so
that /node_modules/ resolves. Any static server does:
npx http-server -p 5200 -c-1
-c-1 disables caching, and it matters more than it looks.
A module URL that does not change is cached hard, so after editing
anything under node_modules the browser keeps running the
old copy and you debug code that is no longer on disk.
The two ways to use it
- Copy-paste. Annotate, click Copy markdown, paste into your agent's chat. Needs no server and no configuration.
- Agent sync. A local broker plus an MCP server let the agent read annotations itself, ask you clarifying questions, and mark them resolved. See connecting an agent.
Everything earmark does.
| Feature | What it means |
|---|---|
| Click to annotate | Hover highlights an element and names it; click to write what should change. |
| Four pick modes | Single element, shift-click multi-select, text selection, dragged region. |
| Verified selectors | Test ids, then id, then semantic attributes, then a scoped path. Every candidate is checked against the document before it is used, and build-hashed class names are filtered out. |
| Source file and line | data-earmark-src stamped at build time for JSX and Svelte, or resolved at annotation time for plain HTML and CSS. |
| Component paths | React fiber, Vue instance and Angular tag walks, best effort, never required. |
| Computed styles | Only properties that differ from their initial value, with layout properties reported only on layout containers. |
| Box geometry | Size and page coordinates, so an agent knows what moved and by how much. |
| Freeze | Pauses CSS animations and transitions, element.animate(), and <video> / <audio>, so you can annotate something that will not hold still. |
| Live pins | Pins re-resolve their element on every render, so they survive reflow and re-renders instead of drifting. |
| Markdown output | One serializer shared by browser, broker and agent, so the text is identical everywhere. |
| Priority | high / normal / low, sorted high first for the agent. |
| Two-way threads | The agent can ask a clarifying question; your answer wakes it again. |
| Status lifecycle | open, acknowledged, needs-input, resolved, dismissed, each with a pin colour. |
| Sessions | One per browser tab, tracking every route you annotated in it. |
| Local broker | REST plus server-sent events plus long-poll, bound to loopback, with an optional token. |
| MCP server | Eleven tools, running the broker in the same process. |
| Storage | JSON, SQLite via node:sqlite, or memory, behind one adapter. |
| Webhooks | Fire-and-forget delivery of annotation events, with a timeout and one retry. |
| Build integrations | Vite plugin (JSX and Svelte), webpack and Turbopack loader for Next.js, and a Svelte preprocessor. |
| CLI | init registers the MCP server; doctor diagnoses the whole chain. |
| TypeScript | Hand-written declarations for every package, with the domain types shared across the wire. |
| Copy-paste fallback | Every failure path degrades to clipboard mode rather than breaking your app. |
Five tools, bottom right.
Press alt+a or click the arrow to start picking.
Escape cancels. Cmd or
Ctrl plus Enter saves an annotation from
the composer.
| Tool | What it does | Use it for |
|---|---|---|
| Pick | Hover highlights an element and names it; click to annotate. Shift-click accumulates several elements, then click to finish. | Almost everything |
| Text | Select text normally; the exact string is captured with the element around it. | Copy changes, typos |
| Region | Drag a box; every element mostly inside it is reported, outermost first. | Layout and spacing |
| Freeze | Pauses animations, transitions, JS-driven animation and media. | Spinners, carousels, toasts |
| Panel | Lists annotations, shows agent replies, and copies the markdown. | Reviewing before you send |
The overlay lives in a shadow root with
pointer-events: none, so your page's CSS cannot restyle
it, its CSS cannot leak into your page, and hit-testing still finds
the real element under the cursor. Picking clicks are swallowed in
the capture phase so your app does not react to them.
Everything createEarmark takes.
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) => {},
})
| Option | Default | Notes |
|---|---|---|
endpoint | http://127.0.0.1:7331 | Broker URL, or false for copy-paste only. Fails quietly when nothing is listening. |
hotkey | alt+a | Accepts alt, ctrl, shift, meta or cmd plus a key. |
theme | auto | auto follows the host page's colour scheme. |
persist | true | Keeps annotations in sessionStorage, which survives a reload and is per tab. |
onAnnotate | none | Called with each annotation as it is saved. |
The mounted instance is returned, and is also on
window.earmark: markdown(),
copy(), clear(),
setMode(mode), openPanel(),
closePanel(), annotations,
sessionId and destroy().
The overlay works anywhere. Source stamping is per bundler.
Nothing here is required. Without it you still get selectors, component names, text and styles, just not a file and line.
Vite, including SvelteKit
// vite.config.js import earmark from 'vite-plugin-earmark' export default { plugins: [react(), earmark()] }
Stamps .jsx, .tsx and
.svelte, and injects the overlay so
createEarmark() in your app code becomes optional.
Options: inject, endpoint,
theme, hotkey, include,
exclude, applyInBuild.
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. Both compilations are stamped, client and server: React hydration will not add an attribute the server HTML did not have.
Verified against Next 16.3, where Turbopack is the
only bundler unless you pass --webpack, in both
next dev and next build: stamps appear in
the server-rendered HTML and in the client DOM, with no hydration
warning. Component tags such as <Card /> are left
alone, because a prop by that name would never reach the DOM.
Next has no index.html to inject into, so mount the
overlay once in a client component:
'use client' import { useEffect } from 'react' export function Earmark() { useEffect(() => { if (process.env.NODE_ENV !== 'development') return import('earmark').then(({ createEarmark }) => createEarmark()) }, []) return null }
Svelte without Vite
// svelte.config.js import { earmarkPreprocess } from 'earmark-stamp' export default { preprocess: [earmarkPreprocess()] }
Components, <svelte:*>, <slot>,
comments, {expressions} and the contents of
<script> and <style> are never
stamped. Stamping is idempotent, so running the plugin and the
preprocessor together is harmless.
Plain HTML and CSS
Nothing to configure. See source resolution.
Three tiers, in order of confidence.
| Tier | How | Reported as |
|---|---|---|
| Build stamp | data-earmark-src written onto the element by the Vite plugin, the Next loader or the Svelte preprocessor. | src/Card.tsx:42:7 |
| Served page | The document is re-fetched and parsed with position tracking, then the element's child-index path is walked in the source. Every step is checked against the live tag name. | index.html:101:11 (resolved from the served HTML) |
| Always | Verified unique selector, exact visible text, component chain, computed styles, box. | No file, but everything greppable |
CSS is resolved separately and works everywhere: every rule the
element actually matches 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.
When the served HTML is a framework shell, the walk mismatches immediately and earmark reports nothing rather than a confidently wrong line. A build-time stamp always wins over resolution.
One process, one setup step.
The MCP server runs the annotation store, the HTTP 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
Then open your app with the overlay mounted. The sync dot in the toolbar turns green when the browser and the broker have found each other. If nothing is listening, the overlay stays fully usable in copy-paste mode.
What the agent can call.
| Tool | Arguments | Purpose |
|---|---|---|
earmark_list_annotations | status, session, priority, format | Outstanding work as markdown, or raw JSON. Defaults to the active statuses and always sorts high priority first. |
earmark_watch_annotations | since, timeout_seconds | Blocks until something changes. A long poll, not a busy loop, and it will not wake an agent with its own writes. |
earmark_get_annotation | id (required) | One annotation with its full reply thread. |
earmark_list_sessions | connected_only | Which browser tabs exist and which routes were annotated in each. |
earmark_get_session | id (required), status | One tab with everything it produced. |
earmark_acknowledge | id (required), note | "Read it, working on it." The pin turns blue. The note is filed as an agent reply. |
earmark_ask | id, question (both required) | Ask instead of guessing. The pin turns amber and your answer wakes the next watch. |
earmark_resolve | id, summary (both required) | Done, with a summary you can read. The pin turns green. |
earmark_dismiss | id, reason (both required) | Declined, with a reason. A dismissal without one is not allowed. |
earmark_clear | none | Delete everything. |
earmark_status | none | Is the overlay connected, which endpoint it should use, and how the counts break down. |
A useful loop for an agent: watch to block,
acknowledge so the human can see it was picked up,
ask if the feedback is ambiguous, then
resolve with a summary.
Five states, five pin colours.
acknowledged exists because an agent halfway through a
refactor otherwise looks identical to an agent that ignored you.
Blue means picked up; green means the edit is actually made.
open, acknowledged and
needs-input are the active statuses, and are what
earmark_list_annotations returns unless you ask for
others. Acknowledged work is outstanding work.
A session is one tab, not one page load.
The id lives in sessionStorage, which is per tab and
survives reloads, so refreshing does not fragment your feedback into
three sessions. Each annotation still carries its own URL, so a
session that wandered across /dashboard,
/settings and /billing hands an agent one
group with three differently routed items.
A tab counts as connected for exactly as long as its
/events stream is open. No heartbeat protocol. Single
page app navigation is tracked too:
pushState and replaceState are patched,
and restored on destroy().
Plain HTTP, if you would rather not use MCP.
npx earmark-server --port 7331
| Route | Does |
|---|---|
GET /health | Liveness, plus annotation and session counts. |
GET /annotations | Everything, with a cursor. |
POST /annotations | What the overlay pushes: sessionId, page, annotations. |
DELETE /annotations | Clear them all. |
GET /annotations/wait | Long poll: blocks until something is newer than ?since=. |
GET /annotations/:id | One annotation. |
PATCH /annotations/:id | Change status, note or priority. |
DELETE /annotations/:id | Remove one. |
POST /annotations/:id/replies | Add to the thread: author, message, optional status. |
GET /markdown | The same markdown the overlay copies. |
GET /events | Server-sent events. Holding this open is what makes a session connected. |
POST /session | Register a tab and its current route. |
GET /sessions | All tabs with their counts. |
GET /sessions/:id | One tab and its annotations. |
Flags: --port, --host,
--store, --file,
--no-persist, --webhook (repeatable),
--token, --quiet.
JSON, SQLite, or nothing at all.
npx earmark-server --store sqlite
npx earmark-server --store memory # nothing on disk
| Backend | File | Behaviour |
|---|---|---|
json (default) | .earmark/annotations.json | Debounced whole-file write. Readable and easy to delete. |
sqlite | .earmark/annotations.db | Uses node:sqlite, built into Node 22.5 and later, so a real database for zero dependencies. Writes are incremental, so a crash loses at most the statement in flight. Falls back to JSON if node:sqlite is unavailable. |
memory | none | Nothing is persisted. |
EARMARK_STORE sets the backend by environment instead of
a flag. Deleting .earmark/ is a supported way to reset.
Annotation events, POSTed out.
npx earmark-server --webhook https://hooks.example/earmark
Also EARMARK_WEBHOOK_URL, or
EARMARK_WEBHOOKS as a comma-separated list. Delivery is
fire-and-forget with a five second timeout, one retry on network
errors and 5xx, and none on 4xx. A hanging endpoint can never stall
the annotation loop, which is a tested property rather than an
intention.
Only annotation events are delivered. Session bookkeeping is durable state, not news, and would be noise downstream.
A webhook sends page URLs, element text and whatever you typed off this machine. That is documented rather than made convenient.
Register it, then diagnose it.
npx earmark-mcp init # merge earmark into .mcp.json npx earmark-mcp doctor # why can the agent not see my annotations?
init preserves any other servers already registered.
doctor checks the chain in the order it breaks: Node
version, then node:sqlite, then MCP registration, then
whether the broker answers, then whether a browser tab is attached.
Each failing check prints the command that fixes it, and the exit
code is non-zero so CI can use it.
✓ 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.
Declarations for every package.
Hand-written, not emitted: the source is JSDoc-typed JavaScript with
no build step, and generating declarations would add one. The domain
types live in earmark and are re-exported by
earmark-server and earmark-mcp, so an
annotation is the same type on both sides of the wire.
import { createEarmark, type Annotation } from 'earmark' const overlay = createEarmark({ theme: 'dark' }) const pending: Annotation[] = overlay.annotations
Exported types include Annotation,
Target, ElementTarget,
RegionTarget, Session,
Status, Priority,
PageContext, Rect and
CssRuleMatch.
A development tool, scoped like one.
- The broker binds
127.0.0.1only. - CORS is open by design: your dev server lives on an arbitrary origin, and the overlay has to reach the broker from it.
-
Any page in your browser can reach a loopback port, so
--tokengates every request when you want that. - Request bodies are capped at 5 MB.
- Not for shared or public machines, and not something to run in production.
The five things that usually go wrong.
| Symptom | Cause and fix |
|---|---|
| The agent sees nothing | Run npx earmark-mcp doctor. It checks the chain in order and prints the command that fixes the first broken link. |
| Sync dot stays grey | Nothing is listening on the endpoint. Start the agent's MCP server, or run npx earmark-server. Copy-paste mode keeps working meanwhile. |
| No file and line | Expected without a build integration. Add the Vite plugin, the Next loader or the Svelte preprocessor, or annotate a plain HTML page, where it is resolved at annotation time. |
| Port already in use | Another editor already started a broker. The MCP server keeps working against its own store and says so in earmark_status. |
| Overlay does not appear | It only mounts in a browser and only when your dev guard allows it. Check that createEarmark() actually ran, or use the data-earmark-auto script tag. |
What it does not do.
- Touch is supported, small screens are not the target. Picking, region drags and the panel all work by touch, and the controls grow on a coarse pointer. It remains a tool for the machine you develop on.
- No screenshots, by decision. Your agent drives a browser and is handed a verified selector plus a URL, so it can capture the element itself at full fidelity. A bundled library would send a re-render of the page rather than what the browser actually painted.
- Cross-origin iframes stay invisible. Same-origin frames are pickable (see below). A cross-origin frame is a browser security boundary:
contentDocumentthrows and there is nothing further to try. - Closed shadow roots are opaque. Open roots are picked into. A root created with
mode: 'closed'exposes nothing to any script, so the custom element itself is annotated instead. - No source line inside a shadow root or a canvas. That markup is script-created and its styles are not in the document's stylesheets, so there is no file to walk. You still get the selector, the reaching expression and the styles.
- Version 0.1.x. All six packages are on npm; none of this has been through a second pair of hands yet.
- A canvas has no DOM. earmark reports its coordinate space instead of pretending otherwise.
Same-origin iframes
Picking descends into any iframe the overlay can read. The annotation carries the element, the frame that holds it, and the frame's URL, because a selector is only unique inside one document:
- **Element:** `<button>` button "Save" - **Selector:** [data-testid="frame-save"] - **Inside iframe:** #preview (preview) - frame document: http://localhost:5173/examples/frames/child.html - the selector above resolves inside that frame, not the top page
Pins are drawn in the top window but re-resolve inside the frame, so they follow the element through reflow. Freeze reaches into frames too, which matters because a preview pane is exactly the thing people want to hold still.
Source resolution follows the frame as well: the frame's own served HTML is walked and its stylesheets are mapped, so a framed annotation reports a file and line inside the frame's document rather than guessing against the parent page.
- **Source:** examples/frames/child.html:19:7 _(resolved from the served HTML)_ - **CSS rules that style it:** - `button` → examples/frames/child.html (inline <style>):9 - `button.primary` → examples/frames/child.html (inline <style>):10
Shadow DOM and web components
Document-level hit testing stops at a shadow host, so a click on a button inside a web component would otherwise be reported as the component. Picking descends through open roots instead, and because no CSS selector crosses a shadow boundary, the annotation carries the host chain and the expression that actually reaches the element:
- **Element:** `<button>` button[shadow-btn] - **Selector:** [data-testid="shadow-btn"] - **Inside shadow DOM:** my-card - reach it with: document.querySelector('my-card').shadowRoot.querySelector('[data-testid="shadow-btn"]') - the selector above is unique inside that shadow root, not in the document
Pins follow the element by walking that same chain. A closed root
exposes no shadowRoot to anyone, so picking correctly
stops at the host.
Canvas and WebGL
There is nothing inside a canvas to select and no source line to point at. What is actionable is the coordinate space the drawing code works in, and in particular the ratio between the drawing buffer and the CSS box, which is where hit-testing bugs live:
- **Canvas:**
- buffer 640×360, CSS 320×180 (2× / 2× per CSS pixel, dpr 2)
- context: `2d`
- renderer: chart.js
- clicked at buffer pixel (354, 200)
- nothing inside a canvas is in the DOM; these coordinates are the handle
Dragging a region over a canvas reports the region in buffer pixels and names the element it was drawn on, rather than only saying the area was empty.
Svelte component chains
Closed. The stamper writes
data-earmark-component alongside the source position,
and the runtime rebuilds the chain by walking ancestors, so a Svelte
annotation now carries App › Dashboard › Card like a
React one.