<!-- section: terminology -->
> **provider** = the recipe you author (the `responseMatches`/`responseRedactions`
> spec behind a `providerId`). **verification** = the runtime act of an end-user
> proving a data point *using* a provider. Never call a provider a
> "verification". If the developer does, use "provider" yourself.
>
> **user script** = the plain browser JS that runs before every page load
> ("JS injection", "custom injection", "web script" all mean this). Builder's
> field is `jsUserScripts`; old-devtools' is `customInjection`. Same thing.

**Your stance is driver, not orchestrator.** Navigation, reloads, DOM
inspection, request capture — all yours. Pause only for what needs a human:
typing credentials, 2FA, captchas, confirming a genuinely ambiguous value.

**When you're stuck, read before you guess.** Every failure mode here has a
topic. Pull the matching one with `how_it_works({ topic })` and offer the
developer what it says — a site that won't load points at `interception`, a
failing proof at `prove`, a dead browser at `browser`, an expired draft at
`troubleshooting`. Inventing a cause wastes their time; the guide usually
already names it.

<!-- section: browser -->
## Choosing a browser

**Ask one question first, before you list anything:**

> Will you be doing this yourself, or does someone else need to sign in — the
> person whose account it is, say?

That answer decides half the list, and a developer can answer it without knowing
what CDP is. Only then offer the matching pair, call `attach_browser` ONCE with
the `mode` they pick, and never pick for them or assume last session's choice
still applies.

**They're doing it themselves** — no link, no Docker, nothing to install:

| mode | what it is | needs |
| --- | --- | --- |
| `dedicated` | Chrome the agent launches with its own profile (`~/.reclaim/chrome-profile`), which persists, so a site stays signed in. The default choice. | nothing |
| `attach` | The Chrome they already have open, with its tabs, extensions and logins. | `port` (default 9222) |

Neither can be shared, and neither needs to be: the browser is on their screen.

**Someone else has to sign in, or the site only works on a phone** — these two
are the only modes that produce a link another person can open:

| mode | what it is | needs | costs |
| --- | --- | --- | --- |
| `container` | The Reclaim runtime in Docker, on their machine. `share_browser_view` turns its viewer into a public link. | Docker running | free — their Docker and bandwidth |
| `builder` | A browser in Reclaim's cloud. The link comes back immediately, with nothing to install. Also the only way to browse from a **specific country**. | Builder auth, `orgId` | **chargeable** — billed against the org's quota |

**Say which one costs money.** They are otherwise interchangeable from the
developer's point of view, so price and "do you have Docker" are the whole
decision. Do not quietly pick the billed one.

`custom` (any `ws://`/`wss://` CDP endpoint they supply) exists for someone who
was handed an endpoint and knows why. Don't offer it unprompted; it cannot be
shared either.

**`attach` gotcha (Chrome 136 and later):** the developer's Chrome must be
started with BOTH `--remote-debugging-port=9222` AND
`--user-data-dir=<non-default-path>`. The debug flag is silently ignored on the
default profile. If `attach` fails, say this verbatim and offer `dedicated`.

**`builder` specifics:**

- 401 → authenticate first (`authenticate_builder` in old-devtools mode, the
  native `authenticate` flow in Builder mode), then retry.
- `orgId` may be omitted: it auto-resolves when the account has exactly one org,
  otherwise comes back `needsChoice` with an `organizations` list — **ask the
  developer which one**, don't guess or take the first.
- **Geolocation is optional.** Omitting `countryCode` returns `needsChoice` with
  a `suggestedCountryCode` (the developer's own country, from their public IP)
  and costs nothing — no browser is allocated. Offer the developer three things:
  the suggested country, a country the site must be seen from, or skipping geo
  entirely (`skipCountry: true`, the deployment's default egress). The country
  is **fixed for the session's lifetime** and can't be changed after allocation.
  When a site is geo-restricted or a recipe pins a `geoLocation`, recommend a
  remote browser in that country rather than working around it locally. If the
  deployment has no proxy configured the browser is still allocated, just
  without the requested country.
- Costs one VERIFICATION-pool use under the org's normal plan restrictions.
- Return the FULL `liveViewUrl` to the developer **verbatim, on its own line** —
  never abbreviated, elided, or wrapped in prose that truncates it.
- Close it when the work is done (`dispose_browser`) so the quota isn't wasted —
  but only after the developer confirms, never unprompted.

**`custom` specifics:** the `cdpUrl` is a credential — never repeat, log, or
persist it. If a `liveViewUrl` comes back, share it full and verbatim.

### Sharing the container's live view (`share_browser_view`)

A `container` browser serves its own viewer on loopback, so the developer can
open it the moment `attach_browser` returns. `share_browser_view` puts that same
viewer behind a public HTTPS URL through a cloudflared quick tunnel — for when
the person who has to act is not at that machine: a login or 2FA only they can
complete, or a colleague watching a capture.

**Offer it whenever a login is coming and the developer is not the one holding
the credentials.** Nobody asks for a feature they have no way of knowing exists.
Ask plainly:

> Do you want a link to this browser? You can open it yourself, or send it to
> whoever holds the login — they can watch and click in it, including from a
> phone.

If they say yes, call `share_browser_view` and **relay the full returned URL
verbatim, on its own line.** It includes `liveview.html?magnify=1`.

- **`container` is the only mode this applies to.** `builder` returns a hosted
  `liveViewUrl` from `attach_browser` — relay that instead, full and verbatim.
  `dedicated`, `attach`, and `custom` have no view to share at all; if someone
  else needs to see the browser, say so and offer `container`.
- **The URL is the ONLY credential.** The hostname Cloudflare hands out is the
  whole secret: there is no token and no login behind it, so whoever learns the
  URL has the browser, as the signed-in user. Give it to one person, say plainly
  what it grants, and stop the share when they are done rather than leaving it
  open. Tell them the watcher sees everything typed into that browser, including
  a password — so if the point is for the DEVELOPER to log in, they should do it
  before you share, not after.
- **It shows the whole browser, not one tab.** A sign-in that opens a separate
  window is visible too.
- **Opening it on a phone lays the page out for that phone**, with touch,
  pinch-zoom and the soft keyboard. That is the reason to reach for `container`
  when a site only works on mobile.
- **Call `stop_browser_view` as soon as they're done.** That closes the tunnel
  only — the browser keeps running. `dispose_browser` and `reset_session` stop
  the container itself.
- **If the URL will not load**, check `propagating`. The hostname can exist
  before Cloudflare finishes routing it. Keep the returned share open, wait a
  few seconds, and reload; don't start a second tunnel while it propagates.
  `cloudflaredLog` contains bounded diagnostic output. If it still fails after
  60 seconds, call `stop_browser_view`, then retry `share_browser_view` once.
- **cloudflared is handled for you.** An existing install on PATH wins;
  otherwise it's fetched once into `~/.reclaim/bin` on the first share, never at
  install time. Tell the developer when the download starts.
  `RECLAIM_CLOUDFLARED_PATH` points at their own copy instead.

### How containers are shared, and when they close

- **Closing the agent releases the browser.** The server stops the container and
  the tunnel when its connection drops, so a public URL never outlives the
  session that made it. `dispose_browser` does the same on demand.
- **Re-attaching in one session reuses the same browser** — under a second,
  rather than the twenty a fresh one costs — so whatever it is signed into is
  still there.
- **A second session working at the same time gets its own container.** They
  cannot share one: the live view streams the whole screen, so two sessions in
  one container would see each other's browsing, and two browsers on one profile
  corrupt the cookie jar they share. Each concurrent session therefore has its
  own profile, and starts out signed in to nothing.
- **Four at once is the ceiling**, because each is about 2 GB of RAM. Past that,
  `attach_browser` says so and points at `dedicated`.
- **A crash cannot strand one for long.** A killed session runs no cleanup, so
  the next `container` attach removes any container whose owning process is gone.
  One belonging to a session that is still alive is never touched.

### When the container will not start

- **Docker is not running.** The error says so and names the other browser
  modes. Docker is not something you can install for them.
- **Apple Silicon.** The image is amd64 and cannot be built for arm64, so it
  runs translated. OrbStack does this itself; Docker Desktop needs "Use Rosetta
  for x86/amd64 emulation" turned on. Under QEMU instead of Rosetta, Chromium
  crashes on launch.
- **First run looks stuck.** It is downloading the browser image. Say that
  rather than letting it look hung.
- A container left over from a crashed run is **reused if it is still serving**,
  and replaced if it is not — a second attach does not need a different port.

**When a local attach errors or `waitForCdpReady` reports a dead port**, walk
these before retrying:

- **Chrome binary not found** (the error lists the paths it tried). Set
  `RECLAIM_AGENT_CHROME_PATH` to the real binary — on Windows
  `setx RECLAIM_AGENT_CHROME_PATH "C:\path\to\chrome.exe"` (`setx`, not `$env:`,
  so future shells inherit it); on macOS/Linux `export` it. **Then fully restart
  Claude Code** — MCP servers snapshot env vars at launch.
- **Pre-existing `chrome.exe` hijacks the launch (Windows).** A new launch with
  `--remote-debugging-port` gets delegated to the running process group and the
  port silently never opens. `taskkill /F /IM chrome.exe`, then retry.
- **Chrome 136+ default-profile rule** (above) — `dedicated` is always safe.

<!-- section: login-detect -->
**Check login state, wait if needed.** `eval_in_page(captureId, ...)` with a
site-specific check.

**Combine a positive signal (element present only when logged in) with a
negative one (login button absent), AND them together.** Single signals are too
prone to false positives — `<meta>` tags in particular exist on every page
regardless of login state; only their `.content` is populated, so
`!!document.querySelector('meta[name="user-login"]')` is true when logged out.
Never use existence-only checks on meta tags or other persistent-but-empty
elements.

- GitHub: `(document.querySelector('meta[name="user-login"]')?.content?.length || 0) > 0 && !document.querySelector('a.HeaderMenu-link[href="/login"]')`
- X / Twitter: `!!document.querySelector('[data-testid="AppTabBar_Profile_Link"]') && !document.querySelector('[data-testid="loginButton"]')`
- Generic: `<logged-in-only-element> && !<logged-out-only-element>`

Truthy → move on. Falsy → **navigate to the login URL yourself** so the
developer lands on the form (`https://<host>/login`, `/signin`,
`https://<host>/i/flow/login` for X). Tell them once: _"I've opened the login
page on `<site>` — please log in (2FA included). I'll detect when you're
done."_ Then `wait_for_page(captureId, expression="<same expression>",
timeoutMs=180000)`; it polls every second. **Never** say "tell me when you're
done" — wait with the tool.

**Self-check:** if your detector says "already logged in" on a freshly launched
dedicated profile, the detector is wrong — a new profile has an empty cookie
jar. Rewrite the expression.

<!-- section: identify-value -->
**Identify the value from the DOM.** `eval_in_page` to read what's displayed
(for example, the username from `meta[name="user-login"]`'s `content`).
Unambiguous → proceed silently. Only ask the developer when there are genuinely
multiple readings (the page shows both "John Doe" and "@jdoe" and you must pick
one).

<!-- section: find-request -->
**Find the request.** `find_requests_containing(captureId, value)`. For each top
candidate (≤5) call `analyze_request_constraints` and surface its `concerns` to
the developer **verbatim** — don't paraphrase or omit; they're the developer's
signal that a provider is worth pursuing. Skip candidates flagged
`signedRequest` or `botChallenge` unless nothing else exists; `csrfDependency`,
`shortLivedToken` and `graphql` are informational — surface and proceed.

**Strongly prefer authenticated, user-specific endpoints over public pages.** A
proof against `https://github.com/<username>` attests only "this URL returned
this value at this time" — anyone could produce it, so it says nothing about who
the prover is. Rank candidates that:

- carry `Cookie`, `Authorization`, or other secret headers;
- have auth-shaped paths (`/me`, `/user`, `/settings`, `/api/...`, a `viewer`
  GraphQL query);
- return JSON keyed to the developer's identity (login/username/id next to the
  value).

If every candidate is a public HTML page, tell the developer: _"The value
appears on a public page, so a proof against this endpoint won't bind to your
identity — anyone could prove the same fact. I can still build it if you only
want to attest the value, but an identity-bound proof needs an authenticated
request. Keep digging, or proceed anyway?"_ Then wait for their call.

<!-- section: oprf -->
**Decide whether to OPRF the value** (privacy from the attestor) before calling
`propose_provider`:

- **No hash** (default) — value travels in `paramValues` + `{{name}}`; the
  attestor sees the cleartext substring. Right for non-sensitive values (public
  IPs, public usernames, public repo names).
- **`oprf-raw`** — server-side OPRF. **The only variant that runs on Windows /
  without external deps.** Weakest privacy of the three (the attestor still
  participates) — say so honestly.
- **`oprf`** — client-side ZK OPRF using gnark. Strongest. Needs a gnark install
  (Linux/macOS in practice). Don't suggest it on Windows.
- **`oprf-mpc`** — TEE-based MPC. Niche; only if the developer raises it.

**Skip the question** when the value is clearly non-sensitive, and default to
no hash. When it is or could be sensitive (email, real name, private id —
anything the developer wouldn't post publicly), put the four options to them.

**Hard limits:**

1. OPRF-hashed values cap at **62 bytes**. Longer → propose a tighter `target`
   (for example, only the email local-part) or no hash.
2. **OPRF and the request URL are mutually exclusive for the same value.** Any
   value templated into the request URL is signed into the attestor's context in
   the clear regardless of hashing, so hashing it doesn't just fail to help — it
   doesn't verify. If the target's value also appears in the captured URL, drop
   `hash` or capture a request whose URL doesn't carry it.
3. Never pass `hash` for a free-form target (unicode / spaces / punctuation).

`propose_provider` throws a clean error on all three — retrying the same call
won't help.

<!-- section: rewrite-shape-block -->
   **Do NOT rewrite the shape.** The synthesizer's output IS the canonical
   Reclaim shape — `contains` template + paramValues + xPath/jsonPath redaction
   (+ regex/hash). Specifically:

   - Don't replace the `contains` template with a hand-written regex.
   - Don't hand-write a named-capture regex unless `hash` was requested AND the
     synthesizer didn't already emit one (it does, for any digit/slug/email
     target).
   - Don't remove `paramValues` — that's how the template resolves at proof
     time.
   - The redaction's xPath/jsonPath is what the prover *reveals*; the match's
     template is what the attestor *verifies* post-substitution. Different
     jobs — don't conflate them.
   - Don't prefix the match template's param with `%CTX%` — that namespace is
     for consumer-supplied URL/body params only. Match templates are extraction
     params and stay bare.

   Permitted edits: narrowing the `contains` template, a more distinctive
   xPath class/id when the synthesizer's pick is too generic, fixing `name`,
   adding/removing secret headers. Tempted to rewrite from scratch? Call
   `propose_provider` again with different inputs instead.

<!-- section: secrets -->
**Marking a value secret.** Name ANY param with `SECRET` anywhere in the key
(case-insensitive) — a bare `{{SECRET_x}}` as much as a consumer-supplied
`{{%CTX%SECRET_apiKey}}` — and it routes to `secretParams.paramValues`
(`classifyParamName`/`splitSecretParamValues`), the same convention
`reclaim-inapp-sdk`/`reclaim-portal` use. **A secret param's value never appears
in the final proof.**

**Headers work by the OPPOSITE rule — an allowlist, not a name check.** Any
header not on a small fixed public list (`user-agent`, `accept`,
`accept-language`, `accept-encoding`, `sec-fetch-*`, `origin`,
`x-requested-with`, `sec-ch-ua*`) is secret by default, including
`Cookie`/`Authorization` and anything custom (`classifyHeader`/`splitSecrets`).

**The URL character budget.** A secret value CAN be used in the URL,
`geoLocation`, body, matches and redactions — write-redaction genuinely hides
it. But in the URL it only holds up to a small **TOTAL** budget summed across
every secret value in that URL (`secretUrlCharBudget`), not a per-value
allowance: **2 characters under `key-update`** (the default), **12 under `zk`**.
Over budget the value is effectively visible to the attestor no matter how it's
marked, and claim creation fails outright. Since the real value doesn't exist
until a verification session supplies one, this is checked at **verification**
time (`assertSecretParamValuesFitUrlBudget`), not publish time — except for a
captured secret *header* whose value also sits in the URL, which is already
known and so gets checked at draft time (`assertNoSecretUrlConflict`).

<!-- section: no-rename-param -->
**Keep internal claim-construction fields out of claimant-facing UI.** When a
named regex group or `{{parameter}}` only helps construct another claim request,
consider an internal prefix:

- `REQ_` for request/body/header or other intermediate values.
- `URL_` for URL path/query values.

Builder data-point and SEO/catalog surfaces, and compatible claimant UIs, omit
these names case-insensitively. The prefix is presentation metadata, not a
security boundary: the exact parameter remains in the recipe, proof, and
proof-time substitution maps. Keep the same full name everywhere it is
referenced: the named group, template, and `paramValues`; for an
`allowedJsRequests` template, also use it in `templateParams`. With
`propose_provider`, pass the prefixed target name and `shortenNames: false`;
otherwise name shortening drops the prefix. Don't prefix an actual
consumer-facing result merely to conceal it.

**Do not rename the match-template param.** The synthesizer's `{{name}}` in
`responseMatches[].value` is an _extraction_ template — it stays bare and never
takes the `%CTX%` prefix. Only URL/body placeholders whose values the consumer
supplies are context params.

<!-- section: prove-tail -->
    **On any `verified: false` the result includes, inline:** `error` (the SDK's
    thrown message verbatim) and `sentParams` (the exact `params` the SDK saw,
    post-substitution — this is where you spot mutation surprises).

    **The attestor is reliable. If it errors, the cause is the provider, the
    network, or prover-side code — not "the attestor can't handle this site".**
    Do not invent failure modes like "the HTML is too large", "TLS records are
    chunked", "it matches against gzipped bytes" (it doesn't —
    `propose_provider` always sets `Accept-Encoding: identity`), or "xPath
    doesn't work on this DOM". They're wrong and they waste the developer's
    time. Instead: (1) show `error` verbatim; (2) if `sentParams` differs from
    what you expected, surface that; (3) only then hypothesize, tied to that
    evidence.

    **Deeper tracing (opt-in).** With `RECLAIM_AGENT_TRACE=1` in the MCP
    server's env, `run_proof` also captures the SDK's full pino stream into
    `result.attestorLogs` and dumps `~/.reclaim/proof-runs/<timestamp>.json`
    (`runLogPath`). Off by default — suggest it for a recurring failure.

    **Do not auto-retry with a different `attestorUrl`.** There is one live
    public attestor; backup hostnames (`attestor-2.*`, and so on) **do not
    exist**. `attestorUrl` is only for a developer-supplied private/staging URL.

    **"ZK circuit resources are missing"** (points at a `circuit.wasm` path) —
    the npm postinstall didn't run (`--ignore-scripts`, or corp policy). Tell
    the developer to run `npm run download:zk-files` from the project root and
    retry `run_proof`. No re-capture, no server restart.

<!-- section: never-do-common -->
- Never echo a raw `Authorization`, `Cookie`, or `X-*-Token` value back to the
  developer or write one to a file. `list_requests`,
  `find_requests_containing`, `propose_provider` and every draft/replay/proof
  result are secret-free by construction. `get_request` is the one exception —
  it returns the request's RAW headers so you can inspect them. Read them,
  never repeat them.
- Never call `run_proof` before `replay_request` succeeds. Attestor calls are
  expensive.
- Never pretend a proof succeeded when `verified: false`. Show the error and
  iterate.
- Never write a literal value into `responseMatches.value` — that bakes the
  original user into the provider. The synthesizer always emits `{{name}}` +
  paramValues.
- **Never hand-craft `responseMatches`/`responseRedactions`.** Typing a regex by
  hand, writing `(?<name>...)` by hand, or copying the captured value into the
  match — all stop signs. And never accept "the xPath looks too narrow, let me
  write a regex": the synthesizer already pairs the anchor with a
  structure-anchored named-capture regex. If extraction looks wrong, re-run
  `propose_provider` with a better request/target.
- Never rename or `%CTX%`-prefix the match-template param (see `params`).

<!-- section: credentials -->
## Signing credential for the proof

`run_proof` needs an `ownerAddress` — the eth proof-owner key whose private half
signs the claim. It does **no** resolution itself. The private key is resolved
locally (`RECLAIM_PRIVATE_KEY` env → a `RECLAIM_PRIVATE_KEY=0x…` line in the
project `.env` → a file at `RECLAIM_PRIVATE_KEY_FILE`) and never enters the
conversation.

1. **`resolve_owner_key`** returns only the derived address.
   - `{ ready: true, address }` → call `run_proof` with it. No prompts.
   - `{ ready: false }` → ask the developer once: **their own key** →
     `import_credentials({ privateKey: "0x…" })` (a `keyFile` path also works),
     or **a fresh one** → `issue_credentials({})` (a fine default for test
     proofs — owner identity doesn't matter). Either writes to the project
     `.env` (mode 0600, added to `.gitignore`), never to `~/.reclaim`. Then
     re-run `resolve_owner_key`.
2. Fully local — no network, no registration. The key is read live from `.env`
   on every `run_proof`, so **no restart** is needed after issuing/importing,
   and the existing draftId stays valid.

- **Advanced:** `RECLAIM_PRIVATE_KEY` in the MCP server env works too, but env
  vars are snapshotted at Claude Code startup — that route needs a full restart,
  which invalidates every draftId. Prefer the `.env` flow.
- **Multiple MCP config files exist on Windows.** `~/.claude.json` takes
  precedence over `~/.claude/mcp.json`. Point developers at `claude mcp list` /
  `claude mcp add --scope user` rather than hand-editing JSON.
