How it works
Describes version 0.9.70. Names in monospace are identifiers in the code. The numbers are read from worker/src/index.js and, where the table says so, extension/content.js; the code stays the source when the two disagree.
There are two moments, and only the second costs anything.
The first is the reply finishing. The content script watches claude.ai's data-is-streaming attribute; when it flips to false (a 1.2 s settle timer is the fallback), the script copies the reply out of the page and stops. It draws one trigger above the message box. It makes no request and no model call. A reply you never ask about never leaves the page.
The second is you pressing that trigger. Only now does it read your own messages off the page and send them with the reply to the backend, or, with your own key, straight to Anthropic. The model returns candidate moves, two gates check each one, and what survives is a row of at most four. If nothing survives, there is no row.
The pipeline
claude.ai reply finishes
└─ content.js detects [data-is-streaming] flipping to false
└─ captures the reply from the DOM, and STOPS
└─ renders one trigger. No model call. Nothing sent.
you click the trigger
└─ content.js reads your own messages from the session,
live off the DOM ([data-testid="user-message"])
└─ { reply, turns[] } goes to the backend
└─ the Worker adds MOVES_SYSTEM, calls Anthropic, enforces the quota
(own key: the extension does the same, without a quota)
└─ two gates run on every returned move
├─ groundMoves: no verbatim quote in your messages
│ or the reply → dropped
└─ enforceAction: label must open with a verb in
ACTION_OPENERS → else dropped
└─ moves earned → a flat row of up to four
nothing → no row at all
└─ you click one; its whole prompt lands in the box
What is read, and how much
The capture is every [data-testid="user-message"] on the page, read at the moment you press. Claude's earlier replies are not read; only the reply you pressed under is. The session is then trimmed to fit, by a policy with a wrong answer that still looks right. The first captured message is pinned: it is the closest thing to a stated goal, and a conversation read without its opening has lost the point of itself. When the budget is exceeded, the oldest middle messages go first, whole messages only, down to a floor of two. A window that kept the last n messages would have tested perfectly and silently decapitated every long session.
| Limit | Value | Constant | Enforced by |
|---|---|---|---|
| Your messages sent | at most 40 | MAX_TURNS | the extension, then the worker |
| Characters per message | 2,000 | MAX_TURN_CHARS | the extension, then the worker |
| Characters of your messages, in total | 12,000 | MAX_TURNS_TOTAL_CHARS | the extension, then the worker |
| Characters of the reply | 6,000 | MAX_REPLY_CHARS | the extension, then the worker |
| Shortest reply that gets a trigger | 120 characters | a literal in scan(), extension/content.js | the extension, before any trigger is drawn |
| Shortest reply the backend accepts | 50 characters | MIN_REPLY_CHARS | the worker, before any upstream call |
| Presses per device per day | 20, reset at midnight UTC | REPLIES_PER_DAY | the worker |
| Presses per hashed IP address per day | 200 | IP_DAILY_LIMIT | the worker |
| Moves returned | 0 to 4 | the prompt, then the gates | |
| Model, shipped default on both paths | claude-sonnet-5 | MODEL (worker), SHIPPED_MODEL (extension) | the worker; the extension, unless a model name is set in its settings |
The caps are applied twice, by the extension before it sends and again by the worker before anything is forwarded, and the second one is what binds: a modified client cannot make a request cost more. With your own key there is no daily limit, and the size caps are the extension's own.
The request, and the two paths
request { reply, turns[] }
response { moves: [{ label, text, evidence }], grounding, quota }
A request with no turns is refused before either quota is charged. Error bodies from upstream are never forwarded to the browser.
hosted extension → Cloudflare Worker → Anthropic
(the worker holds the API key and enforces the quotas)
own key extension → api.anthropic.com
(no server involved, no daily limit)
Both paths send a byte-identical system prompt and run byte-identical gate code. The build script extracts both copies and fails the build if they differ, on the rule that a gate living only in the worker is a gate half the users do not have.
Gate one: evidence
groundMoves checks every returned move for a verbatim quote, over your messages and the reply as two separate haystacks, in that order, so the session's own material is never credited to the reply. Each move records which haystack earned it (sources), and the row tallies them as fromTurns and fromReply. No evidence at all: dropped. A near-miss quote renders, but is counted and logged.
A perfect quote is not proof of a good row. A row transcribed from the reply's own list of options grounds flawlessly. The prompt names that as the most seductive failure: the reply is material, never the subject. Once a session has more than a couple of messages, at least one move has to be earned by something you wrote. It was the first defect the field test found; the entry is on Notes.
Gate two: action
enforceAction drops any move whose label does not open with a doable imperative verb from an allowlist (ACTION_OPENERS, English and Serbian). It fails closed: an unknown verb is a drop, not a warning, so a row of good moves can be emptied by the verb list rather than by the model. The label rule itself is the prompt's, not the gate's: up to six words, naming the action and the thing it acts on, as in Plan three weeks of rehearsal. The code only clamps a label at 60 characters.
An empty row has two causes
The model can honestly earn nothing, or the action gate can drop everything it returned. The grounding result carries total, the number of moves before any gate ran. total = 0 is an honest zero; total > 0 with nothing left is a gate emptying the row. The card says which. It is inert, with no pointer events, because an element that looks clickable would be a floor, and it removes itself after a moment. Until 0.9.64 every cause drew the same card; since 0.9.66 only these two remain.
What lands in the box
Clicking a move puts its whole prompt in the message box in one step, as a single block of text. If you already have a draft there, the prompt goes below it; nothing you typed is replaced. Nothing is sent; you edit and send it yourself. Picking costs nothing, because the messages arrived already written. Everything the model returns (labels, texts, evidence) is rendered as text, never as markup, because it is generated text drawn into a third-party page. The row for a reply is kept in the extension's session storage (chrome.storage.session: gone when the browser closes, sixty entries, oldest out, errors never kept), so pressing twice under the same reply does not spend twice.
Known limits
- On a very long conversation claude.ai keeps only the rows near where you are in the page and unloads the rest, so the capture sees the tail, not the whole conversation. The numbering the model sees starts at the earliest message still on the page, not at the first message of the conversation, and the prompt says so: the earliest message it is given "is not guaranteed to be the conversation's true first", and it is told to "treat it as the oldest thing you can see rather than as the beginning". The extension logs to the browser console how many messages it read and their positions, so a short contiguous run on a long conversation can be recognised as a truncated read.
- The selectors for claude.ai's own elements are pinned constants. If claude.ai changes its structure, the extension goes quiet rather than breaking the page.
- The action gate's verb list is an allowlist, so a good move with a verb it does not know is dropped. The list is extended as drops are read; the reading is on Notes.
- Zero moves is a real outcome. How often a press earns nothing in real use is what the field test is measuring.
- Not built: a prompt library or templates with variables; cross-device sync, which would need accounts, and the device token is anonymous by design.
Install it from the Chrome Web Store, or read the source: the content script is extension/content.js, the backend is worker/src/index.js, and the gates are duplicated across both on purpose.