How it works

How it works

Describes version 0.9.99. 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, and, to size the thread, asks claude.ai's own API from your browser how large the conversation is (refineThread; on a Cowork session, coworkRead takes the exact token count from the session record). That reading stays on your device. It makes no request to the backend 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, through the same API, 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 your own messages in the conversation, read at the moment you press through claude.ai's conversation API (apiThread), because the page keeps only the rows near where you are and unloads the rest. On a Cowork session, which is a Claude Code Remote session, coworkTurns reads the first page of the event stream, where the goal is, and the last three thousand events, where the present is. If the API does not answer, every [data-testid="user-message"] still on the page is the fallback. 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.

Limits, and where each one binds
LimitValueConstantEnforced by
Your messages sentat most 40MAX_TURNSthe extension, then the worker
Characters per message2,000MAX_TURN_CHARSthe extension, then the worker
Characters of your messages, in total12,000MAX_TURNS_TOTAL_CHARSthe extension, then the worker
Characters of the reply6,000MAX_REPLY_CHARSthe extension, then the worker
Shortest reply that gets a trigger120 charactersa literal in scan(), extension/content.jsthe extension, before any trigger is drawn
Shortest reply the backend accepts50 charactersMIN_REPLY_CHARSthe worker, before any upstream call
Presses per device per day20, reset at midnight UTCREPLIES_PER_DAYthe worker
Presses per hashed IP address per day200IP_DAILY_LIMITthe worker
Moves returned0 to 4the prompt, then the gates
Model, shipped default on both pathsclaude-sonnet-5MODEL (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.

Same session, new chat: the exit from a thread

The moves are ways into the thread you are in. Same session, new chat is the way out, and it is drawn only when there is a thread worth leaving. threadTokens sizes the conversation (the API count when it answers, the page at four characters per token when it does not); past LONG_THREAD_TOKENS, 12,000, the card's label row carries the number re-read on every send and the control. Pressing it reads the session exactly as a press for moves does and asks for a brief instead: FORK_SYSTEM, byte-identical on both paths like the moves prompt, returns the goal, what is settled, what is open, in a few hundred tokens, capped at FORK_MAX_TOKENS, 2,000. A brief cut at the ceiling is salvaged at a clean line and marked partial, never padded. It spends one of the day's twenty.

The brief renders as one chip. Clicking it parks the brief with the service worker (stageBrief, session storage, two-minute life, consumed on read) and opens the new conversation: claude.ai/new for a chat, or on Cowork the session's project page, whose address is the session record's chat_project_id decoded (it is the project's uuid in base58). The content script loading there finds a message box with no messages and puts the brief in it, unsent. No URL carries the text.

Why this is the control that earns a place, in numbers from one working day of one session on the API:

One working day in one session: what the model read against what it wrote
TokensCount
Re-read from cache, every turn96,300,000
Written by the model470,000
Ratio200 to 1

Cached re-reads are billed at a tenth of the input price and still made up 58% of that day's cost. The subscription limit discounts them too, and still counts them: five messages in a 689k-token session spent 9% of the five-hour limit, the same five in a new chat with the brief 3%. The measurement and its caveats are on the overview and in the handoff.

Known limits

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.