CONTEXA

Claude prompts, without the writing

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.

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.

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.