earmark v0.1

Documentation

earmark turns a click in your running app into structured context an agent can act on: a verified selector, the source file and line, the component path, the computed styles and the exact box. This page is the reference for all of it, from installing the overlay to the eleven tools an agent calls.

01 Install

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.
02 Feature list

Everything earmark does.

FeatureWhat it means
Click to annotateHover highlights an element and names it; click to write what should change.
Four pick modesSingle element, shift-click multi-select, text selection, dragged region.
Verified selectorsTest 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 linedata-earmark-src stamped at build time for JSX and Svelte, or resolved at annotation time for plain HTML and CSS.
Component pathsReact fiber, Vue instance and Angular tag walks, best effort, never required.
Computed stylesOnly properties that differ from their initial value, with layout properties reported only on layout containers.
Box geometrySize and page coordinates, so an agent knows what moved and by how much.
FreezePauses CSS animations and transitions, element.animate(), and <video> / <audio>, so you can annotate something that will not hold still.
Live pinsPins re-resolve their element on every render, so they survive reflow and re-renders instead of drifting.
Markdown outputOne serializer shared by browser, broker and agent, so the text is identical everywhere.
Priorityhigh / normal / low, sorted high first for the agent.
Two-way threadsThe agent can ask a clarifying question; your answer wakes it again.
Status lifecycleopen, acknowledged, needs-input, resolved, dismissed, each with a pin colour.
SessionsOne per browser tab, tracking every route you annotated in it.
Local brokerREST plus server-sent events plus long-poll, bound to loopback, with an optional token.
MCP serverEleven tools, running the broker in the same process.
StorageJSON, SQLite via node:sqlite, or memory, behind one adapter.
WebhooksFire-and-forget delivery of annotation events, with a timeout and one retry.
Build integrationsVite plugin (JSX and Svelte), webpack and Turbopack loader for Next.js, and a Svelte preprocessor.
CLIinit registers the MCP server; doctor diagnoses the whole chain.
TypeScriptHand-written declarations for every package, with the domain types shared across the wire.
Copy-paste fallbackEvery failure path degrades to clipboard mode rather than breaking your app.
03 Using the overlay

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.

ToolWhat it doesUse it for
PickHover highlights an element and names it; click to annotate. Shift-click accumulates several elements, then click to finish.Almost everything
TextSelect text normally; the exact string is captured with the element around it.Copy changes, typos
RegionDrag a box; every element mostly inside it is reported, outermost first.Layout and spacing
FreezePauses animations, transitions, JS-driven animation and media.Spinners, carousels, toasts
PanelLists 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.

04 Options

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) => {},
})
OptionDefaultNotes
endpointhttp://127.0.0.1:7331Broker URL, or false for copy-paste only. Fails quietly when nothing is listening.
hotkeyalt+aAccepts alt, ctrl, shift, meta or cmd plus a key.
themeautoauto follows the host page's colour scheme.
persisttrueKeeps annotations in sessionStorage, which survives a reload and is per tab.
onAnnotatenoneCalled 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().

05 Framework setup

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.

06 Source resolution

Three tiers, in order of confidence.

TierHowReported as
Build stampdata-earmark-src written onto the element by the Vite plugin, the Next loader or the Svelte preprocessor.src/Card.tsx:42:7
Served pageThe 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)
AlwaysVerified 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.

07 Connecting an agent

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.

08 The eleven tools

What the agent can call.

ToolArgumentsPurpose
earmark_list_annotationsstatus, session, priority, formatOutstanding work as markdown, or raw JSON. Defaults to the active statuses and always sorts high priority first.
earmark_watch_annotationssince, timeout_secondsBlocks until something changes. A long poll, not a busy loop, and it will not wake an agent with its own writes.
earmark_get_annotationid (required)One annotation with its full reply thread.
earmark_list_sessionsconnected_onlyWhich browser tabs exist and which routes were annotated in each.
earmark_get_sessionid (required), statusOne tab with everything it produced.
earmark_acknowledgeid (required), note"Read it, working on it." The pin turns blue. The note is filed as an agent reply.
earmark_askid, question (both required)Ask instead of guessing. The pin turns amber and your answer wakes the next watch.
earmark_resolveid, summary (both required)Done, with a summary you can read. The pin turns green.
earmark_dismissid, reason (both required)Declined, with a reason. A dismissal without one is not allowed.
earmark_clearnoneDelete everything.
earmark_statusnoneIs 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.

09 Statuses

Five states, five pin colours.

open acknowledged needs input resolved dismissed

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.

10 Sessions

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

11 Broker API

Plain HTTP, if you would rather not use MCP.

npx earmark-server --port 7331
RouteDoes
GET /healthLiveness, plus annotation and session counts.
GET /annotationsEverything, with a cursor.
POST /annotationsWhat the overlay pushes: sessionId, page, annotations.
DELETE /annotationsClear them all.
GET /annotations/waitLong poll: blocks until something is newer than ?since=.
GET /annotations/:idOne annotation.
PATCH /annotations/:idChange status, note or priority.
DELETE /annotations/:idRemove one.
POST /annotations/:id/repliesAdd to the thread: author, message, optional status.
GET /markdownThe same markdown the overlay copies.
GET /eventsServer-sent events. Holding this open is what makes a session connected.
POST /sessionRegister a tab and its current route.
GET /sessionsAll tabs with their counts.
GET /sessions/:idOne tab and its annotations.

Flags: --port, --host, --store, --file, --no-persist, --webhook (repeatable), --token, --quiet.

12 Storage

JSON, SQLite, or nothing at all.

npx earmark-server --store sqlite
npx earmark-server --store memory   # nothing on disk
BackendFileBehaviour
json (default).earmark/annotations.jsonDebounced whole-file write. Readable and easy to delete.
sqlite.earmark/annotations.dbUses 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.
memorynoneNothing is persisted.

EARMARK_STORE sets the backend by environment instead of a flag. Deleting .earmark/ is a supported way to reset.

13 Webhooks

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.

14 CLI

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.
15 TypeScript

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.

16 Security

A development tool, scoped like one.

  • The broker binds 127.0.0.1 only.
  • 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 --token gates 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.
17 Troubleshooting

The five things that usually go wrong.

SymptomCause and fix
The agent sees nothingRun npx earmark-mcp doctor. It checks the chain in order and prints the command that fixes the first broken link.
Sync dot stays greyNothing 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 lineExpected 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 useAnother editor already started a broker. The MCP server keeps working against its own store and says so in earmark_status.
Overlay does not appearIt 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.
18 Limits

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: contentDocument throws 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.