annotepage

Review notes on a web page

Annotate a page.
The assistant answers in the thread.

Someone reviewing a staging site clicks what they see — a heading, a button, an image — and writes what is wrong. An assistant reads the notes, fixes the code, replies saying what it measured, and marks the remark resolved with the version the fix ships in.

  • Open source · MIT
  • Self-hostable · one PHP codebase
  • End-to-end encrypted · in the browser
Review notes Close
Camille30 Aug 2026, 14:02
About: Start free trial

The label wraps below 380px and the button grows a second line.

Resolved on 30 Aug 2026, 16:20 by Assistant Fix shipped in version 1.4.13
Assistant30 Aug 2026, 16:18

Measured at 360px: the label wrapped at 372px and the button grew to two lines. The call to action now uses a fluid font-size and the label is one word shorter; it holds down to 320px.

The reviewer sees what became of what they wrote, on the page where they wrote it. Nothing is deleted: a resolved remark moves into the history and can be reopened.

Put it on a site

One script tag at the end of <body>. No framework, no bundler, no separate stylesheet. One file, no dependencies.

01

Generate the salt, once

Load the client on any page of the site with data-setup and without data-project. The setup screen generates a 256-bit salt and hands you four things to copy: the salt, the project id, the finished tag, and the lines to declare on the server. No network request is made at that point.

setup — remove once the project exists
<script src="https://cdn.jsdelivr.net/npm/annotepage-client@2.0.0/dist/annotepage.js"
        integrity="sha384-A5Wrzv2mtFVnn8Mt0xC7BglTbxbb75unG3CNt5YHUNQ6X5QcwfFPI0OWtANjN2V/"
        crossorigin="anonymous"
        data-server="https://your-server.example.com/annotepage/api.php"
        data-setup
        defer></script>

Salt lost = notes lost. The salt is the only secret of the project. It never leaves the browser, the server receives it in no form whatsoever, and nobody can give it back to you — no recovery, no security question, no escrow. Put it where your team keeps its passwords before continuing. There is no salt rotation either: a leaked salt means starting a fresh project and abandoning the notes already written.

02

Paste the tag it gives you

At the end of <body>, on the pages to annotate. The project id is public; it is derived from the salt and is 22 characters.

the finished tag
<script src="https://cdn.jsdelivr.net/npm/annotepage-client@2.0.0/dist/annotepage.js"
        integrity="sha384-A5Wrzv2mtFVnn8Mt0xC7BglTbxbb75unG3CNt5YHUNQ6X5QcwfFPI0OWtANjN2V/"
        crossorigin="anonymous"
        data-server="https://your-server.example.com/annotepage/api.php"
        data-project="7Qb1kZ3xNvA9dLpEqKf2Zt"
        data-version="1.4.12"
        data-environment="staging"
        defer></script>

integrity is not decorative. Once the client comes from a CDN, the real risk of this architecture is the supply chain: a file swapped at the CDN's host runs in your page, with access to localStorage — hence to the salt. The digest makes that swap useless, and crossorigin="anonymous" goes with it: without it the browser does not check the digest of a cross-origin resource.

The digest above is the one for 2.0.0. Every version has its own, printed by the build and listed in dist/HASHES.txt inside the package. Never copy a digest from a page documenting another version: the browser will refuse the file, and that is exactly its job.

Do not add type="module". A module script has no document.currentScript: the client could no longer read its own attributes, and would stand down in silence.

data-version earns its place. When a note is marked resolved, the tool compares the version of the fix with the one the site declares it is serving, to tell “resolved and online” — which folds into the history — from “resolved, not deployed yet”, which stays in front of the reviewer, because the defect is still on screen. Missing or unreadable version: the fix is taken as not deployed.

03

Declare the project on the server, hand out the salt

The server is one PHP codebase. Drop it on the site itself, or on one machine that serves several sites. It receives the project id and the list of origins allowed to consume it — never the salt, which travels out of band: the tool provides no channel for it.

internal/config-local.php
'deployment' => 'self-hosted',      // or 'relay'

'projects' => array(
    '7Qb1kZ3xNvA9dLpEqKf2Zt' => array(
        'origins' => array('https://staging.example.com',
                           'https://www.example.com'),
        'mode'    => 'encrypted',
    ),
),

Those three facts — the id, the origins, the mode — are exactly what the setup screen dictates to you at step 01. The rest of that file is the database and the defaults; start from config-local.example.php. Without the file the server answers nothing at all, which is the safe default.

Each reviewer pastes the salt once. The tool checks that it really derives the project id the page declares, then remembers it in that browser, for that origin. See server/INSTALL.md.

What it requires, and nothing else: a recent browser in a secure contexthttps, or localhost. Without one the browser does not provide WebCrypto, and the tool can neither encrypt nor even compute the page index; it says so on screen rather than pretending. And a reachable annotepage server.

Three gestures

At rest the tool is one button, bottom right. It adds a single element at the end of <body> and works inside a shadow root: no class, no attribute and no style lands on an element of your page.

Open

One button, and a count of the notes already on this page. It is the tool's only trace when it is at rest.

Point

Click what you see. The tool draws its own rectangle around it — it never puts an outline on your element. Escape stops.

Write

Your name is remembered for next time. The remark is sealed in the browser, then pinned to that element for everybody.

What goes out is an AES-256-GCM envelope: the text, but also the page, the selector, the excerpt, the name, the version, the environment and the viewport. What comes back is pinned to the element again by its selector and its fingerprint — and when the element has really changed, the note is listed as an orphan rather than pinned to the wrong thing.

Once in place the tool no longer keeps quiet. Every failure is shown, with the message the server wrote, and the text typed stays in the form. A remark believed saved and not saved is worse than no tool at all.

How an assistant uses it

Two commands to plug one in. Then it reads the review, replies in the thread, and stamps the fix with the version it ships in.

plug it in
npm install -g annotepage-mcp
claude mcp add annotepage -- annotepage-mcp

The same package installs an annotepage command, for a script, a CI job, or an assistant with no MCP client. It holds the project salt, so it is the only place the plaintext comes back. Node 18 or later, no dependencies.

01

Read what is still open

command
annotepage open --page /pricing
what it returns
tool annotepage
format 2
version 2.0.0
project 7Qb1kZ3xNvA9dLpEqKf2Zt
encryption yes
export 2026-09-01T09:14:22+00:00
notes 1

note 12
page /pricing
page-index 9dLpEqKf2Zt8ArC1vXbQ3s
element button.cta
excerpt Start free trial
mode encrypted
author Camille
date 2026-08-30T14:02:11+00:00
version 1.4.12
environment staging
viewport 360x780
status open
text
    The label wraps below 380px and the button grows a second line.

One fact per line, key value, two-space steps for replies. The remark arrives with the page, the element, the excerpt, the version, the environment and the viewport it was written at — enough to reproduce it without asking anyone.

02

Answer in the thread, then stamp the fix

command
annotepage reply 12 "Measured at 360px: the label wrapped at 372px and the button grew to
two lines. The call to action now uses a fluid font-size and the label
is one word shorter; it holds down to 320px."

annotepage resolve 12 1.4.13
annotepage note 12
tool annotepage
format 2
version 2.0.0
project 7Qb1kZ3xNvA9dLpEqKf2Zt
encryption yes
export 2026-09-01T09:31:05+00:00
notes 2

note 12
page /pricing
page-index 9dLpEqKf2Zt8ArC1vXbQ3s
element button.cta
excerpt Start free trial
mode encrypted
author Camille
date 2026-08-30T14:02:11+00:00
version 1.4.12
environment staging
viewport 360x780
resolved 2026-08-30T16:20:09+00:00 by Assistant in 1.4.13
text
    The label wraps below 380px and the button grows a second line.

  reply 14
  to note 12
  mode encrypted
  author Assistant
  date 2026-08-30T16:18:52+00:00
  status open
  text
      Measured at 360px: the label wrapped at 372px and the button grew to
      two lines. The call to action now uses a fluid font-size and the label
      is one word shorter; it holds down to 320px.

That thread is what the reviewer sees at the top of this page, on the element they clicked. The version is the point: the client compares 1.4.13 with the version the site declares it is serving. Newer than the site, and the note stays under the reviewer's eyes, because the defect is still on screen.

The seven tools an assistant gets

ToolWhat it does
annotepage_open_notesthe remarks still open, optionally for one page
annotepage_read_noteone note with its replies
annotepage_replyanswer in the thread
annotepage_mark_resolvedresolve, stating the version the fix ships in
annotepage_reopenput a note back, when the fix was incomplete
annotepage_exportthe whole review as text
annotepage_projectsthe projects this configuration knows

The package never creates a note. A note is pinned to an element of a page; an assistant has no browser and no element to point at, so what it manufactured would be undisplayable where it counts. The thread of an existing note is the place provided for it to speak. Nothing is ever erased: resolving and reopening are the only two state changes.

Every write is signed with the name in the configuration. A thread where everybody signs and one voice does not is a thread you doubt entirely. Set "read_only": true when plugging an assistant onto a review you do not know yet: it cuts every write.

Or use nothing at all

In plain mode — self-hosted only — the server serves the same document, and any assistant that can fetch a URL reads the whole review with no integration whatsoever.

plain mode, no package
curl 'https://staging.example.com/notes/api.php?action=text&project=<id>'

In encrypted mode that same address returns only the structure — note numbers, page indexes, dates, status — because the server has neither the paths, nor the names, nor the texts. A reader therefore knows, with no special-case code, that it is missing the salt. annotepage text is the step that fills it in, and it emits the same grammar: a tool reading the export cannot tell which of the two produced it.

Where the notes live, and who can read them

You choose the server: the site itself, or a separate machine hosting several projects. Either way the notes are encrypted in the browser by default, and whoever runs the server — including you — cannot read the review.

One secret, three keys

HKDF-SHA-256 derives everything from the 256-bit salt: the public project id (22 characters), the AES-256-GCM key, and an HMAC key for the page index. One secret to manage, and it never leaves the browser.

Grouped without being read

The server groups pages by a blind index: the first 16 bytes of an HMAC of the path, which it cannot invert. It nests replies, dates writes and sorts open from resolved without opening anything.

Everything observed, sealed

Not just the text. Encrypting the remark alone would hand over the site's tree, its wording and the list of its reviewers — and a staging site is precisely what has not been published yet.

The domain is not in the key

The day staging becomes production, the notes stay readable. Each reviewer pastes the salt once more on the new domain; the notes themselves do not move.

What the server sees anyway

Encrypting the fields is not invisibility. An operator must be able to read this without feeling betrayed, and you must know it before choosing where to put the server.

  • The number of projects, and of notes in each.
  • The number of distinct pages annotated, and the notes per page: a page that collects forty remarks is visible.
  • The time of every write, hence the rhythm of the review and the date of the last note.
  • The shape of the threads, and the fix rate and delay, through the resolution dates.
  • The approximate length of every remark: the size of the envelope gives it away to within a few bytes. It is not masked.
  • The IP address and user agent of every reviewer, like any HTTP server.
  • On a shared server, the domain of the site under review, through the Origin header the domain lock has to read. The promise is not “it does not know which site you are reviewing”; it is “it can read neither your paths, nor your names, nor your remarks”.

The domain lock is an anti-abuse measure, not an XSS defence. It stops another site consuming a project id found in the source of a page. An XSS runs inside the target page, so with the legitimate origin: it goes through the lock without effort, and it reaches localStorage anyway — hence the salt. The XSS defence is elsewhere: the tool assigns text through textContent and never parses it as markup, and a check in npm run check enforces it.

What it deliberately does not do

These are choices. Saying them is more useful than discovering them later.

  • No accounts, no login. The name typed in is a convenience, not an identity. The project id is a bearer token: whoever has it can read and write — and in encrypted mode, what they read is useless without the salt.
  • No moderation, no deletion. A note that is posted stays. The only state it can change is resolved, and that can be taken back.
  • No notifications. Nobody is told that a note has arrived.
  • No salt rotation, and no channel for handing the salt to the second reviewer.
  • No overview across pages. Open a page, or read the export.
  • No screenshots attached to a note — weighed, and not settled: the reasons are written down in the roadmap.

data-mode="plain" exists, and is only acceptable when self-hosted, where encryption protects nothing: the notes sit in the same database, on the same machine, behind the same access restriction as the site under review. A shared relay refuses it with a 400, and shows its message.