# Discover Harness — Session & Wire Tradecraft (keyboard-level reference)

The executable field layer the browser-driving dimensions (`session`, `wire-capture`) reach for at the keyboard. Everything here is **runnable verbatim** — taps, scrubbers, escape snippets — not prose to re-derive. It is the distilled tradecraft from real discovery runs; the `§1.x`/`§2.x` anchors are the origin tags from those runs.

**How the dimensions use this file:**

- `session` / `wire-capture` install the **Tap toolkit** taps first, then traverse via in-app nav.
- Every captured value passes through the **runnable redaction layer** — which lives in the ethics contract (`ingestion.md` §7, "Redaction — runnable") because redaction is non-negotiable — before it is returned, logged, or written to `raw/`.
- Read the **Harness gotchas** before every run; reach for **Dead-end recovery** when a surface walls.
- `discovery` (fingerprinting) and `evaluation` (classifying a finding) also cite the **fingerprint tables** here.

> Provenance: ported verbatim from the prior-generation playbook's `general-tricks.md` / `general-saas.md`. Keep it living — fold every new gotcha / tap / fingerprint back in (the same maintenance loop the redaction table carries).

---

## Tap toolkit (copy-paste in-page interceptors)

The runnable in-page instrumentation `session`/`wire-capture` install to mine the authenticated runtime. Each snippet is **idempotent** (early-exits if already installed via a `window.__xTap` sentinel) so it is safe to re-run after any DOM-changing action.

**Two operating rules bind every snippet here:**

- **Re-install after every full navigation.** The JS context is wiped on full page loads, `location.reload()`, and any anchor click that triggers a full nav. SPA-internal `pushState`-routed nav preserves it (the in-page wrapper survives client-side nav — which is exactly why client-side nav is how you capture a fresh on-load socket; see ingestion §9.4). Full navs do not.
- **`SCRUB` is defined in the ingestion redaction section** (`ingestion.md` §7 / the source toolkit §2.1, §4). Every value a tap returns to the harness — and every value before it reaches `raw/` — passes through `SCRUB`. The taps below capture **shapes, not secrets**; pair them with `SCRUB`/`redactSecrets` before anything is returned, logged, or written.

> **Sentinel gotcha (ingestion §9.4 / source §2.9):** never wrap `fetch` twice. A second body interceptor breaks `response.clone()` from the inner wrapper. Install **one** tap of each kind — the sentinel guard below is what enforces that.

---

### 1. Fetch + XHR interceptor (URL / method / body shapes)

**When to use:** the primary capture for any REST/JSON/SSE-over-fetch app — install it FIRST, before navigating, then traverse via in-app nav. Logs request URL/method/body and a clamped response preview; CDP `read_network_requests` is an unreliable supplement that misses in-flight + cached requests.

```js
(() => {
  if (window.__fetchTap) return "already";
  window.__fetchTap = [];
  const orig = window.fetch.bind(window);
  window.fetch = function (input, init) {
    const url = typeof input === "string" ? input : input.url;
    const method = (init && init.method) || (input && input.method) || "GET";
    const entry = { url, method, at: Date.now() };
    if (init && typeof init.body === "string")
      entry.body = init.body.slice(0, 8000);
    window.__fetchTap.push(entry);
    return orig(input, init).then((resp) => {
      try {
        entry.status = resp.status;
        entry.respCt = resp.headers.get("content-type") || "";
        const c = resp.clone();
        if (/json|text\/plain|event-stream/.test(entry.respCt)) {
          c.text()
            .then((t) => {
              entry.respLen = t.length;
              entry.resp = t.slice(0, 12000);
            })
            .catch(() => {});
        }
      } catch (_) {}
      return resp;
    });
  };
  if (!window.__xhrTapped) {
    window.__xhrTapped = true;
    const origOpen = XMLHttpRequest.prototype.open;
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (method, url) {
      this.__url = url;
      this.__method = method;
      return origOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function (body) {
      const rec = {
        kind: "xhr",
        url: this.__url,
        method: this.__method,
        // clamp MATCHES the fetch tap (8000) — see the clamp-parity note below
        body: typeof body === "string" ? body.slice(0, 8000) : "[non-string]",
        at: Date.now(),
      };
      window.__fetchTap.push(rec);
      this.addEventListener("load", () => {
        try {
          rec.status = this.status;
          rec.respCt = (this.getResponseHeader("content-type") || "").split(";")[0];
          const t = this.responseText;
          if (typeof t === "string") {
            rec.respLen = t.length;
            rec.resp = t.slice(0, 12000);
          }
        } catch (_) {}
      });
      return origSend.apply(this, arguments);
    };
  }
  return "tapped";
})();
```

> Apply `SCRUB` (redaction section) to `entry.body` / `entry.resp` before returning or writing — body capture is clamped to 8000–12000 chars; larger bodies go to a `window.__RAW_*` global and are paged (ingestion §5, size discipline).

---

### 1b. The clipboard-to-local channel (unlimited size, bypasses BOTH content filters)

**When to use:** any captured body that exceeds the `javascript_tool` display cap (as low as ~1.1 KB) or
that contains blocklisted shapes (query strings, long base64, many UUIDs) which would return
`[BLOCKED: Cookie/query string data]`. Paging a 90 KB body at ~1 KB a slice costs ~90 round trips; this
costs one. It is also immune to Chrome's silent multi-download block.

**Requires document focus** — click the page once before calling it, or `execCommand('copy')` returns `false`.

```js
window.__CP = (obj) => {
  const ta = document.createElement("textarea");
  ta.value = typeof obj === "string" ? obj : JSON.stringify(obj, null, 1);
  ta.style.cssText = "position:fixed;left:0;top:0;width:100px;height:60px;opacity:0.01;z-index:2147483647";
  document.body.appendChild(ta); ta.focus(); ta.select();
  ta.setSelectionRange(0, ta.value.length);
  const ok = document.execCommand("copy"); ta.remove();
  return "copied=" + ok + " len=" + ta.value.length;   // returns only a length — never the payload
};
```

Then read it locally — the payload never passes through the model's context or the content filter:

```bash
pbpaste > capture.json      # macOS
xclip -o  > capture.json    # Linux
```

> **⚠ VERIFY THE PASTE — `copied=true` is NOT proof the OS clipboard changed.** `execCommand('copy')`
> reports success from *inside the page* and can still leave the system clipboard holding a **previously
> copied payload** — most reliably when Chrome is not the OS-focused window (the agent's terminal is).
> A naive `pbpaste > raw/x.json` then writes **the wrong body** into `raw/` under a confident `_meta`,
> which is the worst artifact class there is: a stale file that scores silently.
>
> **Always assert the length matches what the tap reported**, before trusting the file:
>
> ```bash
> EXPECT=14866                      # the len= the __CP call returned
> GOT=$(pbpaste | wc -m | tr -d ' ')   # -m = CHARACTERS, never -c (bytes). See below.
> [ "$GOT" -ge $((EXPECT-100)) ] && [ "$GOT" -le $((EXPECT+100)) ] \
>   && pbpaste > raw/capture.json && echo "ok len=$GOT" \
>   || echo "STALE CLIPBOARD: expected ~$EXPECT, got $GOT — do NOT write"
> ```
>
> **Compare CHARACTERS (`wc -m`), not BYTES (`wc -c`).** JavaScript's `String.length` counts UTF-16 code
> units; `wc -c` counts UTF-8 bytes. Any non-ASCII payload makes the two diverge badly — Devanagari,
> Bengali, Tamil, CJK and emoji are 3–4 bytes per character, so a perfectly fresh 16,378-character capture
> measures 19,740 bytes and a byte-based guard **rejects a good artifact**. Allow ±100 for the trailing
> newline and minor encoding slack. (Origin: indus-sarvam — this guard's own first use produced a false
> positive on a Hindi-language agent transcript; the clipboard was fine and the check was wrong. On a
> multilingual target the byte form fails *more often than it succeeds*.)
>
> Cheap corroboration: also compare a head token (`pbpaste | head -c 40`) against the head the tap
> returned. **Escalation ladder when it is stale:** (1) click inside the page and retry — it is flaky, not
> permanently broken; (2) retry once more with the textarea left mounted a beat longer; (3) fall back to
> paging via `javascript_tool` slices (budget ~1 KB of *usable* output per slice, not the 3 KB the slice
> size suggests); (4) if paging is uneconomic, write a **decoded** artifact with `verbatim: false`,
> `_meta.why_not_verbatim` naming the failure, and `_meta.refetch` carrying the exact command — an honest
> decoded artifact beats a confident wrong one. (Origin: indus-sarvam — a 14,866-byte flag payload
> reported `copied=true` three times while `pbpaste` kept returning a 195,216-byte body copied minutes
> earlier. Caught only because the length was checked.)

**Scrub before copying**, not after: build the object with `__SCRUB` first, then `window.__CP(pack)`. The
clipboard is local, so this stays inside the no-exfiltration rule (ingestion §7 rule 7).

> (Origin: Emergent, second run — the prior run recorded router model-ids as "masked `[variant]`" and 6 of
> 77 agent rows as display-cap truncated. Both were artifacts of the *display* layer, not the capture: the
> same values came back verbatim through the clipboard channel. **The display layer is not the capture
> layer.**)

> **Clamp parity is load-bearing — the XHR branch must match the fetch branch (Mode-5, Emergent).** Many
> SPAs call their own API through **axios/XHR, not `fetch`** (Emergent does). An XHR branch clamped smaller
> than the fetch branch silently truncates exactly the payload you care about most — a real run lost the
> primary write body (`POST /jobs/v0/submit-queue/`) to a 600-char clamp and had to re-wrap mid-capture.
> Keep both at 8000 (request) / 12000 (response).
>
> **Re-wrapping XHR mid-run is safe; re-wrapping `fetch` is not.** The double-wrap hazard is specific to
> `fetch` + `response.clone()` (gotcha §2.9): a second body interceptor breaks the inner wrapper's clone.
> XHR has no clone semantics, so installing a second, wider XHR wrapper (under a fresh sentinel such as
> `window.__xhr2`) is a legitimate mid-run recovery when you discover the first clamp was too small.

---

### 1c. Pre-shutter redaction overlay (run before EVERY screenshot)

**When to use:** any `save_to_disk` capture (`ingestion.md` §5.5). Redaction must happen **in the DOM before
the shutter** — a leaked pixel is undetectable afterwards, because the §7.0 4c literal sweep is text-only and
`grep` cannot read a PNG.

Idempotent; re-run it after every navigation and before every capture, since the elements needing redaction
change as you move. Extend `PII` with the current session's own identifiers at run time (never commit them).

```js
(() => {
  if (!document.getElementById("__redact_style")) {
    const st = document.createElement("style");
    st.id = "__redact_style";
    st.textContent = `.__redacted{background:#111 !important;color:transparent !important;
      border-radius:3px !important;text-shadow:none !important}
      .__redacted *{visibility:hidden !important}
      .__blurred{filter:blur(14px) !important}`;
    document.head.appendChild(st);
  }
  // 1. account identity — extend per run (name / email / org / workspace slug)
  const PII = /REPLACE_WITH_RUN_IDENTIFIERS/i;
  let n = 0;
  document.querySelectorAll("body *").forEach(el => {
    if (el.children.length === 0 && el.textContent && PII.test(el.textContent)) {
      el.classList.add("__redacted"); n++;
    }
  });
  // 2. THIRD-PARTY SURFACES — the corner-of-the-frame trap. Blur wholesale; do not enumerate their text.
  const THIRD_PARTY = [
    '[class*="intercom" i]','[id*="intercom" i]','iframe[src*="intercom"]',
    '[class*="crisp" i]','[id*="crisp" i]','[class*="drift" i]','[class*="zendesk" i]',
    '[class*="beacon" i]','[class*="helpscout" i]','[class*="freshchat" i]',
    '[class*="toast" i]','[class*="notification" i]','[role="alert"]','[role="status"]',
    '[class*="presence" i]','[class*="avatar-group" i]','[class*="online" i]'
  ];
  document.querySelectorAll(THIRD_PARTY.join(",")).forEach(el => { el.classList.add("__blurred"); n++; });
  // 3. avatars anywhere
  document.querySelectorAll('img[src*="avatar"],img[src*="googleusercontent"],[class*="avatar" i] img')
    .forEach(el => { el.classList.add("__blurred"); n++; });
  return "redacted_nodes=" + n;
})()
```

> **Then LOOK at the saved image before asserting it is clean.** The overlay handles what you predicted;
> the frame may hold what you did not. Record `redacted: <what>` in `screens/_index.md` — never a blanket
> "no PII in the pixels" claim you did not verify by reading the file (`ingestion.md` §5.5 origin note).

---

### 2. WebSocket constructor wrapper (with binary-frame signature handling)

**When to use:** any realtime/collab/streaming layer carried over WebSocket. Catches only sockets created **after** install — a library already initialised at page-boot (Pusher/Ably) is invisible here; use tap 4 instead. For an on-load socket, client-side-nav to a fresh room so a new socket is constructed through your wrapper (ingestion §9.4).

```js
(() => {
  if (window.__wsTap) return "already-tapped";
  window.__wsTap = [];
  const OriginalWS = window.WebSocket;
  window.WebSocket = function (...args) {
    const ws = new OriginalWS(...args);
    window.__wsTap.push({ kind: "open", url: args[0], at: Date.now() });
    ws.addEventListener("message", (e) => {
      window.__wsTap.push({
        kind: "recv",
        url: args[0],
        data: typeof e.data === "string" ? e.data.slice(0, 4000) : "[binary]",
        at: Date.now(),
      });
    });
    ws.addEventListener("close", () =>
      window.__wsTap.push({ kind: "close", url: args[0], at: Date.now() }),
    );
    const origSend = ws.send.bind(ws);
    ws.send = (data) => {
      window.__wsTap.push({
        kind: "send",
        url: args[0],
        data: typeof data === "string" ? data.slice(0, 4000) : "[binary]",
        at: Date.now(),
      });
      return origSend(data);
    };
    return ws;
  };
  Object.assign(window.WebSocket, OriginalWS);
  return "tapped";
})();
```

> **Never log a binary frame as opaque `[binary]`** (ingestion §9.4). The snippet above stubs binary as `[binary]` for the string-clamp path; for any `ArrayBuffer`/`Blob` frame replace that branch with a **signature** capture — byte-length + first ~16 bytes as hex AND decoded as ASCII (a legible prefix names STOMP/Phoenix/Centrifugo/`+topic` in one step before guessing protobuf/MessagePack/CBOR/Yjs). Drop this into the `message`/`send` handlers in place of the `'[binary]'` literal:

```js
const frameSig = (d) => {
  if (typeof d === "string")
    return { kind: "text", len: d.length, data: d.slice(0, 4000) };
  const buf = d instanceof ArrayBuffer ? d : d && d.buffer ? d.buffer : null;
  if (!buf) return { kind: "unknown" };
  const head = new Uint8Array(buf.slice(0, 16));
  const hex = [...head].map((b) => b.toString(16).padStart(2, "0")).join(" ");
  const ascii = [...head]
    .map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : "."))
    .join("");
  return {
    kind: "binary",
    byteLen: buf.byteLength,
    head16Hex: hex,
    head16Ascii: ascii,
  };
};
```

---

### 3. EventSource wrapper

**When to use:** apps that stream via the `EventSource` API. Always run alongside tap 1 — many products read `text/event-stream` over `fetch().body` instead of `EventSource`, where this tap returns empty (source §2.3); the fetch tap catches those.

```js
(() => {
  if (window.__esTapped) return "already";
  window.__esTapped = true;
  window.__esTap = [];
  const OrigES = window.EventSource;
  window.EventSource = function (url, init) {
    const es = new OrigES(url, init);
    window.__esTap.push({ kind: "es-open", url, at: Date.now() });
    es.addEventListener("message", (e) => {
      window.__esTap.push({
        kind: "es-msg",
        url,
        data:
          typeof e.data === "string" ? e.data.slice(0, 4000) : "[non-string]",
        at: Date.now(),
      });
    });
    es.addEventListener("error", () =>
      window.__esTap.push({ kind: "es-err", url, at: Date.now() }),
    );
    return es;
  };
  Object.assign(window.EventSource, OrigES);
  return "tapped";
})();
```

---

### 4. Realtime-SDK global catalog listener (Pusher / Ably `bind_global`)

**When to use:** a realtime library (Pusher, Ably, Socket.IO, Centrifugo, Phoenix) was initialised as a global **before** your WebSocket wrap installed — its socket used the unwrapped constructor and is invisible to tap 2. Hook the library's own event-bus instead; `bind_global` yields decoded event names + channels + payloads (more robust than walking `p.connection.connection.transport.socket`).

**For Pusher:**

```js
(() => {
  if (!window.Pusher || !window.Pusher.instances || !window.Pusher.instances[0])
    return "no-pusher";
  if (window.__pusherTapped) return "already";
  window.__pusherTapped = true;
  window.__pusherLog = [];
  const p = window.Pusher.instances[0];
  p.bind_global((eventName, data) => {
    window.__pusherLog.push({
      ts: Date.now(),
      evt: eventName,
      channels: Object.keys(p.channels.channels),
      data:
        typeof data === "string"
          ? data.slice(0, 800)
          : JSON.stringify(data || {}).slice(0, 800),
    });
  });
  return JSON.stringify({
    state: p.connection.state,
    cluster: p.config.cluster,
    key: p.key,
    channels: Object.keys(p.channels.channels),
  });
})();
```

> **For Ably:** `window.Ably.Realtime.instances` exposes the same pattern. The returned `{ state, cluster, key, channels }` is the realtime-handshake summary; the accumulating `window.__pusherLog` is the event/channel catalog. Pass the `key` field through `SCRUB` before returning.

---

### 5. Idempotent re-install guard

**When to use:** every tap above — and any `window.__*` capture global — must early-exit if already installed, so re-running a snippet after a DOM-changing action does not double-wrap (`fetch` wrapped twice breaks `response.clone()`; source §2.9). The guard is the first line of each tap; for capture registries that should _survive and accumulate_ within a navigation rather than early-exit, use the `||=` init form.

```js
// Guard form — early-exit if a wrapper is already installed (use atop every tap):
(() => {
  if (window.__xTap) return "already"; // sentinel: unique per tap (__wsTap, __esTap, __fetchTap, __pusherTapped, ...)
  window.__xTap = [];
  // ... install wrapper here ...
  return "tapped";
})();

// Init form — idempotent registry that survives & accumulates within one navigation
// (re-init after every full nav; the global is wiped on reload — source §2.11):
window.__probe = window.__probe || {};
```

| Sentinel                                       | Guards tap                  |
| ---------------------------------------------- | --------------------------- | --- | ----------------------------- |
| `window.__fetchTap` / `window.__xhrTapped`     | Fetch + XHR (item 1)        |
| `window.__wsTap`                               | WebSocket (item 2)          |
| `window.__esTapped` / `window.__esTap`         | EventSource (item 3)        |
| `window.__pusherTapped` / `window.__pusherLog` | Pusher/Ably global (item 4) |
| `window.__probe` (init-form `                  |                             | =`) | per-navigation probe registry |

> The guard is a **per-navigation** sentinel: a full `navigate()` / `location.reload()` wipes both the wrapper and its sentinel, so re-running the snippet after a full nav correctly re-installs. Within a single SPA session, the sentinel prevents the double-wrap.

---

## Harness gotchas (Chrome MCP-specific) — read before every run

The Chrome-MCP browser-driving layer has ~27 recurring failure modes; every one bit a real run. Each entry below is **failure mode → concrete workaround**. They bind the `session` and `wire-capture` dimensions (the only browser-driving dimensions); the runnable taps/escapes are EXECUTABLE, not prose — paste them as-is.

1. **Output that _looks_ like cookie/query data is blocked** (`[BLOCKED: Cookie/query string data]`) — a `javascript_tool` result with JWT-shaped strings, long base64, long hex, many UUIDs, or query-string substrings is blocked on the _returned string_, not on what the code does → run the returned value through a `SCRUB()` (and, when that's not enough, the `ULTRA()` regex) before returning. _(§2.1)_
2. **`read_network_requests` only tracks AFTER its first call** — initial page-load requests are invisible (it starts capturing on first invocation) → install the fetch/XHR taps **before** navigation, or reload after the first call. _(§2.2)_
3. **`read_network_requests` misses cross-origin XHRs + cache-served loads (GraphQL SPAs)** — it may not surface cross-origin API XHRs (e.g. `i.jasper.ai/gw/graphql` from `app.jasper.ai`), and on reload data is served from the NuStack/SWR cache so no request fires at all → don't conclude "no API calls"; pivot to the client cache and force a refetch to capture verbatim bodies. _(§2.2 corollary)_
4. **`EventSource` taps miss `text/event-stream` over `fetch`** — many products read SSE-formatted bodies via `fetch().body`, not `EventSource`, so the ES tap returns empty even while streaming → always run **both** the EventSource tap and the fetch/streaming-body tap. _(§2.3)_
5. **Long-poll buffers rotate fast on heartbeats** — heartbeats fly every few seconds and evict interesting frames from bounded buffers → skip frames smaller than ~350 bytes when storing, and dedupe events by `id` so retried polls don't re-add. _(§2.4)_
6. **`find` returns ghost duplicates in dynamic UIs** — modern apps render a textarea+button pair twice (visible+hidden, or in a portal); `find` returns both refs but only one is wired → default to coordinate clicks for action-bearing buttons; use refs only for stable structural elements. _(§2.5)_
7. **`Enter` in textareas doesn't submit; contentEditable divs aren't textareas** — the submit handler is bound to a button, not the keypress, and many apps use `contentEditable` divs as fake textareas (`form_input` won't work) → click the submit button by coordinate after typing; on contentEditable use coordinate-click + `computer.type`. _(§2.6)_
8. **`wait` caps at 10s** — a single `wait` won't hold longer → batch multiple `wait` calls, or use `browser_batch` with several `wait` actions; inside JS, `await new Promise(r => setTimeout(r, 4000))` works for ≤45s waits. _(§2.7)_
9. **Don't dump raw tokens** — the harness (correctly) blocks raw `document.cookie` / `Authorization` dumps even when the user is OK seeing structure → inspect **shape** only (lengths, names, decoded JWT payload with PII redacted), never raw values. _(§2.8)_
10. **`response.clone()` chains break if you wrap fetch twice** — a `clone()` from an inner wrapper may not work in the outer → install **one** tap and do everything in it; don't layer multiple body interceptors. _(§2.9)_
11. **Sleep/wait is never your loop** — if you're doing `wait → check → wait → check`, server-side state is more reliable than babysitting client polling → switch to: trigger action → wait once → query the trajectory/history endpoint directly. _(§2.10)_
12. **JS context is wiped on every full navigation** — `window.__TOK/__TAP/__RAW/__probe/__DUMP/...` are all gone after `navigate()`, `location.reload()`, or any anchor click triggering a full load → re-install the tap after every navigation, re-grab the token from localStorage, and use idempotent init (`window.__X = window.__X || {...}`). SPA-internal `pushState`-routed nav preserves context (keep your taps), **but** `history.pushState` + `dispatchEvent(new PopStateEvent('popstate'))` may trigger a _full_ nav in Next.js App Router — don't rely on programmatic SPA-internal nav; use real link clicks or just navigate. _(§2.11)_
13. **WebSocket tap doesn't catch sockets created before installation** — a library (Pusher, Ably) that inits its WebSocket at app-boot via `window.WebSocket` is invisible to a wrap installed after that point → use the library-specific event-bus tap (e.g. Pusher `bind_global`) instead. _(§2.12)_
14. **Page renderer freezes during agent generation** — `find`, `screenshot`, `read_page` wait for `document_idle` and hang while the page constantly mutates (streaming output) → for liveness checks during generation, use `javascript_tool` (no idle wait). _(§2.13)_
15. **`cloned.text()` never resolves on long-poll bodies** — the body tap reads via `response.clone().text()`, which only resolves on response _close_; a held-open long-poll never closes, so the promise never resolves → capturing chunks-as-they-arrive needs `getReader()`-based decoding; for most RE, skip streaming-chunk capture and hit the `/history` endpoint instead. _(§2.14)_
16. **Be aggressive with body-capture limits** — when wrapping fetch, slice the response-body capture to 8000–12000 chars; the tap entry's `resp` field is for previewing only → anything bigger goes into a `window.__RAW_*` global and is paged through (the global is for the full body). _(§2.15)_
17. **Output-truncation tells** — the harness truncates very long outputs at ~3–5KB; watch for `[TRUNCATED]` markers → either page-through via `slice()` or write the data to a file via the Write tool (skip the JS round-trip). _(§2.16)_
18. **Slices get truncated even at 3000 chars** — empirically the harness truncates each slice output to ~2.5–3KB _regardless of slice size_ → use **3000-char slices** as a defensive default and scrub aggressively before returning; for 100KB+ bodies switch to the aggregate-summary-from-window pattern (write decoded summaries + samples, not verbatim bodies). _(§2.17)_
19. **`tabs_context_mcp` is required at the start of any session** — tab IDs from previous conversations are stale and won't work → always call `mcp__claude-in-chrome__tabs_context_mcp` first to confirm tab IDs and that the tab group is alive. _(§2.18)_
20. **`read_network_requests` filters URL _substring_, not regex** — pass `urlPattern: "/api/"` for a literal substring match; no regex, no globs → don't try `urlPattern: "/api/*"` (it's treated as the literal substring `/api/*`, rarely what you want). _(§2.19)_
21. **`read_network_requests` is cumulative within a domain** — the panel accumulates across the session and only clears on cross-domain nav OR when you pass `clear: true` → for per-route mining, clear the panel before navigating to each new route (`{tabId, urlPattern: "...", clear: true}`) so the request set is scoped to that page. _(§2.20)_
22. **`browser_batch` outputs are individually capped, not jointly** — each item's output is independently capped (~3–5KB) → fit ~10 `slice()` calls in one batch for ~30KB total across separate output blocks (each item still truncates individually if too long). _(§2.21)_
23. **A SyntaxError inside a batch fails the rest of the batch** — one bad `javascript_tool` item in a `browser_batch` can stop the rest from executing → test JS expressions in isolation before batching them. _(§2.22)_
24. **CORS-walled serverless RPC is a hard read-only stop** — cross-origin fetch from the SPA origin to a Cloud Functions / Cloud-Run RPC host fails preflight (`TypeError: Failed to fetch`); POST-only RPC bodies/headers aren't probeable read-only → mine the **Firestore IndexedDB cache** (`collectionParents` census, `remoteDocuments` cached bodies+schemas, `targets` active subscriptions) + `redux-persist` slices; a Firestore REST `GET documents/users/{uid}` (200) confirms the Bearer model, `listCollectionIds` (403) is the negative control proving security rules are active. _(§2.23 — Plai)_
25. **`403 "Token is not valid."` (not 401) = wrong-auth-scheme tripwire, not broken auth** — on single-host dual-auth-surface products, the app Bearer JWT hitting a _public-developer-API_ path returns a 403 with a token message → conclude it's the wrong scheme for that path, not that the token is bad. _(§2.24 — Creatify)_
26. **Onboarding gating can blank the whole live surface — an un-code-split bundle is the rescue** — when every route redirects to `/onboarding` for an incomplete tenant, no live traffic is harvestable → string-mine a single un-split SPA bundle (Omneky's 5.74 MB `index-*.js`) for the full API-path catalog + route table, and fill in confirmed schemas from the pre-onboarding-reachable endpoints (brand-setup/reference/account). Treat an un-split bundle as a gift, not a nuisance. _(§2.25 — Omneky)_
27. **Probe Bearer vs cookie vs raw-token explicitly** — a JS-readable token cookie does NOT imply cookie-auth; on Creatify and Omneky the API rejects the raw cookie (`credentials:'include'` → `Failed to fetch` under `ACAO:*`) and accepts only an explicit `Authorization: Bearer` → probe all three (Bearer header / cookie / raw-token) to pin the real header model before mining. _(§2.26)_
28. **Dynamic/runtime-constructed realtime hub URLs can't be recovered by bundle regex** — AdCreative's SignalR hub path is built at runtime in `initializeHub` with no static literal → record realtime _presence_ + transport as confirmed, the URL + message contract as open (needs Pass 2). Don't report "no realtime" just because grep found no socket URL. _(§2.27 — AdCreative)_

29. **`javascript_tool` does NOT await promises — an `async` IIFE returns `{}`** — the tool stringifies the pending Promise, so `(async () => { const r = await fetch(...); return r; })()` yields a bare `{}` that looks identical to a content block or an empty result → **never `await` at the top level.** Use fire-and-forget + poll: fire the requests storing results on `window.__X`, return a literal like `"fired"`, then poll `window.__X` in a second call. Three captures were silently lost to this before it was diagnosed. _(§2.28 — Emergent, second run)_

### The escape snippets (paste verbatim)

**Fire-and-forget poll — CDP `Runtime.evaluate` times out at ~45s; a fetch that holds the connection open longer (long-poll, slow stream) must be fired, then polled (§1.10):**

```js
// Pass 1: fire
(() => {
  window.__done = false;
  fetch(url, { headers })
    .then((r) => r.text())
    .then((t) => {
      window.__raw = t;
      window.__done = true;
    });
  return "fired";
})()(
  // Pass 2..N: poll
  () => "done=" + window.__done + " len=" + (window.__raw || "").length,
)();
```

**Window-storage + pagination — when a fetched body is larger than the harness output budget (~3–5KB), store it in a global and page through it; use 3000-char slices to leave headroom for the truncation tell, and scrub each slice before returning (§1.9):**

```js
// Pass 1: fetch + store
(async () => {
  const r = await fetch(url, { headers });
  window.__RAW = await r.text();
  return "len=" + window.__RAW.length;
})()(
  // Pass 2..N: slice
  () => window.__RAW.slice(0, 3000),
)()(() => window.__RAW.slice(3000, 6000))();
// etc.
```

**SCRUB — neutralise blocked patterns on any string returned from `javascript_tool` (§2.1):**

```js
const SCRUB = (s) =>
  String(s || "")
    .replace(/eyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+/g, "<jwt>")
    .replace(/[A-Za-z0-9+/]{40,}={0,2}/g, "<b64>")
    .replace(/data:image\/[^"]+/g, "<data-image>")
    .replace(
      /[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g,
      "<uuid>",
    )
    .replace(/[a-f0-9]{32,}/g, "<hex>")
    .replace(/(user|org|ins|sess|cli)_[A-Za-z0-9]{20,}/g, ".$1id.") // Clerk-style IDs
    .replace(/\+91\d{10}/g, "<phone-in>") // Indian phone numbers
    .replace(/\+1\d{10}/g, "<phone-us>") // US phone numbers
    .replace(/\b\d{10,15}\b/g, "<phone>") // generic numeric phone
    .replace(/[?&]([a-z_]+)=([^&\s"]+)/gi, (_, k, v) => "?" + k + "=<v>");
```

**ULTRA scrub — last resort when SCRUB still isn't enough (agent-generated content matching the blocklist); loses JSON braces but keeps structural information (§2.1, §4.2):**

```js
const ULTRA = (s) => String(s || "").replace(/[^a-zA-Z0-9 _.,:!?\-/]/g, ".");
```

---

## Dead-end recovery — when X is walled, try Y

When a surface is genuinely closed read-only, the failure mode is a recovery cue, not a stop. Each row pairs the wall with the pivot; the runnable ones carry the exact escape.

| When X is walled | …try Y | Anchor |
| --- | --- | --- |
| **CORS-walled serverless RPC** (Cloud Functions / Cloud-Run, POST-only, preflight `TypeError: Failed to fetch`) | mine the **Firestore IndexedDB cache** — `collectionParents` (census), `remoteDocuments` (cached bodies+schemas), `targets` (active listeners) + `redux-persist` slices. `GET documents/users/{uid}` (200) confirms Bearer; `listCollectionIds` (403) is the negative control proving rules are active | §2.23 — Plai |
| **Onboarding gate blanks the surface** (every route → `/onboarding`, no live traffic) | string-mine the **un-code-split bundle** for the full API-path catalog + route table; fill confirmed schemas from pre-onboarding-reachable endpoints (brand-setup / reference / account) | §2.25 — Omneky |
| **`403 "Token is not valid."`** on a path | NOT broken auth — it's a **wrong-auth-scheme tripwire**: the app Bearer JWT is hitting a _public-developer-API_ path on a dual-auth host. Switch scheme for that path, don't discard the token | §2.24 — Creatify |
| **Sleep/wait loop** (`wait → check → wait → check`) | trigger the action, **wait once, then query the history/trajectory endpoint** directly — server-side state beats babysitting client-side polling | §2.10 |
| **`cloned.text()` never resolves** (held-open long-poll) | don't chase streaming chunks — hit the `/history` (or `/state`, `/snapshot`, `/page-metadata`) endpoint instead | §2.14 / §1.8 |
| **JS-readable token cookie** tempts `credentials:'include'` | a readable cookie ≠ cookie-auth — probe **Bearer header / cookie / raw-token** all three to pin the real header model; the API often rejects the raw cookie under `ACAO:*` and requires explicit `Authorization: Bearer` | §2.26 |
| **`read_network_requests` shows only telemetry** (cross-origin XHRs / cache-served reload, GraphQL SPA) | pivot to the **NuStack/client cache** (`window.__NUSTACK_CLIENT__.cache.extract()`) and **force a refetch** to capture verbatim wire bodies — don't conclude "no API calls" | §2.2 corollary / §1.13 |
| **Realtime hub URL absent from bundle grep** (runtime-constructed, e.g. SignalR `initializeHub`) | record realtime **presence + transport as confirmed**, URL + message contract as **open (needs Pass 2)** — never report "no realtime" on a grep miss | §2.27 — AdCreative |
| **Authoring/builder surface paywalled** (input/form schema hidden) | open the **run flow** instead (it fetches the richer record incl. the input form), capture the typed-input union, optionally run **one** cheap action to capture the write mutation + output record — deep server-side bits (system prompt, model) stay open, don't fabricate | §1.14 — Jasper |
| **GraphQL introspection disabled** (`{ __schema }` → 400 "introspection is not allowed") | fall back to **cache census + wire-body capture** — the POST body still carries the full query text; force a re-send via `refetchQueries` and the fetch tap captures it verbatim | §1.13 — Jasper |

**The "skip the wait" anchor (§1.8) — the engine of read-only Pass 1: once you have an entity ID, hit its `/history`/`/state`/`/snapshot`/`/page-metadata` endpoint with the page's existing token; one call returns an entire log / column catalog / state, equivalent to hours of streaming observation. Always check for it first.**

```js
(async () => {
  // For Bearer-token products:
  const tok = JSON.parse(
    localStorage.getItem("sb-auth-auth-token"),
  ).access_token;
  const id = "<entity-uuid>";
  const r = await fetch("https://api.example.com/.../" + id + "/history", {
    headers: { authorization: "Bearer " + tok },
  });
  return await r.json();
})();
```

For same-origin cookie-auth products (Clerk, Next.js axios proxies):

```js
(async () => {
  const r = await fetch("/api/conversations/page-metadata?agentIds=<uuid>", {
    credentials: "include",
  });
  return await r.json();
})();
```

---

## GraphQL read-side mining + classification fingerprint tables

Two reusable assets for the `session` dimension (ingestion §9.4 runtime taps). Asset 1 is an **executable read-side recipe** for any NuStack/GraphQL SPA; Asset 2 is a set of **classification tables** that turn a raw observation (a storage key, a host, an envelope shape) into a _named_ stack finding. Every code block below is runnable verbatim in the page console / in-page tap — do not paraphrase it into prose.

---

### 1. The NuStack-cache read-side recipe (the richest Pass-1 GraphQL source)

> **Why this is the richest Pass-1 GraphQL source.** For NuStack-backed SPAs (look for `window.__NUSTACK_CLIENT__`), the **normalized client cache is the single richest read-only source** — often better than network capture, which may miss cross-origin GraphQL XHRs and won't fire at all when data is served from cache on reload (a real run on Jasper saw the network panel show only telemetry because the reload was served entirely from the NuStack cache). The cache holds a normalized entity census, every entity's field schema, and enum distributions across all entities — the whole read-side data model — with **zero network cost and no state change**. Introspection is almost always disabled in prod (`{ __schema { ... } }` → `400 "introspection is not allowed"`), so the cache census + verbatim wire-body capture _is_ the schema-recovery path, not a fallback.

### 1a. Entity census, field schema, and enum distributions

Dump the normalized cache, count entities per type, decode one entity of a type to get its field schema, and compute enum distributions across all entities of that type:

```js
// 1) entity census — what types exist and how many
const ex = window.__NUSTACK_CLIENT__.cache.extract();
const counts = {};
Object.keys(ex).forEach((k) => {
  const t = k.split(":")[0];
  counts[t] = (counts[t] || 0) + 1;
});
// 2) decode one entity of a type = its field schema
const sample = ex[Object.keys(ex).find((k) => k.startsWith("Task:"))];
// 3) enum distributions across all entities of a type
const tasks = Object.keys(ex)
  .filter((k) => k.startsWith("Task:"))
  .map((k) => ex[k]);
const dist = (f) =>
  tasks.reduce((c, t) => ((c[t[f]] = (c[t[f]] || 0) + 1), c), {});
```

### 1b. List observable queries (operation names + variables + AST)

Recover every live operation's name, variable keys, and selection-set AST — even when the production build has stripped the query source text:

```js
const arr = [...window.__NUSTACK_CLIENT__.getObservableQueries("all").values()];
arr.map((q) => {
  const d = q.query || q.options.query;
  return {
    name: d.definitions.find((z) => z.operation)?.name?.value,
    vars: Object.keys(q.variables || {}),
    sel: d.definitions[0].selectionSet /* walk for fields */,
  };
});
```

### 1c. Force a refetch to capture verbatim query/mutation text off the wire

Production strips `DocumentNode.loc.source.body`, but the **POST body still contains the full query**. Install a fetch tap that keeps `init.body`, then **force a re-send** so the tap captures the exact query string + fragments + variables:

```js
await window.__NUSTACK_CLIENT__.refetchQueries({ include: ["Web_Library"] });
```

The fetch tap then holds the verbatim query/mutation text + fragments + variables for that operation. (This is how the Jasper `Web_Library` / `Web_CreateAppSnapshot` queries and the 11-type `ContextItem` union were recovered.)

> **Other GraphQL clients:** `urql` exposes `__URQL_CLIENT__`; Relay stores an environment with a `Store` — apply the same census → operations → force-refetch pattern against those handles.

---

### 2. Cross-cutting inference / fingerprint tables (classify what a finding _is_)

These tables let a raw observation be **classified** — a storage key prefix names the stack, a key+host pair names the auth provider, an envelope shape names the backend framework, and the realtime-trap table stops a run concluding after one channel.

### 2.1 Storage-key prefix → stack inference

Inferences from `localStorage` / `sessionStorage` / cookie / IndexedDB **key names alone** (no values):

| Prefix | Stack signal |
| --- | --- |
| `sb-*-auth-*` | Supabase Auth |
| `firebase:*`, `firebaseLocalStorage` (in IndexedDB) | Firebase Auth + Firestore |
| `clerk-*`, `__clerk*` | **Clerk** |
| `__client_uat*`, `__session*` (cookies) | Clerk session cookies |
| `auth0.*` | Auth0 |
| `persist:*` | Redux Persist |
| `_pinia_*` | Pinia (Vue state) |
| `nustack-cache-persist` | NuStack Client |
| `i18next*` | i18next |
| `ph_phc_*` | PostHog |
| `_ga*`, `_gid` | Google Analytics |
| `__mpq_*`, `mp_*_mixpanel` | **Mixpanel** |
| `_fbp` | Facebook Pixel |
| `intercom-*` | Intercom |
| `pusher*` | Pusher |
| `ably-*` | Ably |
| `rzp_*` | Razorpay |
| `stripe.*` | Stripe |
| `<something>-prod-*.a.run.app` (in network) | Google Cloud Run |
| `*.appspot.com` | Google App Engine |
| `*.lambda-url.*.amazonaws.com` | AWS Lambda Function URL |
| `ov-*.ecs.us-west-2.on.aws` | AWS ECS Fargate |
| `accounts.<root>.com`, `clerk.<root>.com` | Clerk on a Satellite Domain (enterprise; cookies are first-party) |
| `static.prod-images.*` | Public CDN for screenshots/assets |

> **Don't forget IndexedDB.** Firebase Modular SDK v9+ stores tokens in IndexedDB (`firebaseLocalStorageDb` / `firebaseLocalStorage` object store), **not** localStorage. A "nothing auth-related in localStorage" result is a finding pointing you at IndexedDB next, not a dead end.

### 2.2 Auth-provider fingerprint (storage keys + hosts → provider)

| Provider pattern | Tells (storage keys + hosts + token shape) |
| --- | --- |
| **Supabase Auth, gateway-validated** | `sb-*-auth-token` in localStorage, HS256, **no `apikey` header** on calls (= a gateway sits in front) |
| **Firebase Auth, IndexedDB-stored** | `firebaseLocalStorageDb` IndexedDB object store, RS256, ~1-hour token, auto-refreshed |
| **Clerk** (standard) | `__clerk_db_jwt`, `__session` cookie, `clerk-*` localStorage |
| **Clerk Enterprise** (Satellite/Proxy Domain) | Clerk JS hosted on `clerk.<root>` (not `clerk.com`); JWT issuer is the proxy domain; cookies are first-party |
| **Auth0 / OIDC** | `auth0.*` localStorage, RS256, often hybrid (cookie + Bearer) |
| **WorkOS / Stytch / Ory** | Passwordless flows, often opaque tokens, magic-link or SSO |
| **Cognito / AWS Amplify** | `CognitoIdentityServiceProvider.*` localStorage, RS256 |
| **Self-hosted IdentityServer (Duende/IdentityServer4) OIDC** | `at+jwt` RS256 carrying short claims + classic WS-Federation claim URIs; very long TTL (~180 d); token is a **field inside a localStorage JSON blob** (`oidc-client-ts` idiom), not a bare key |
| **Self-hosted JWT** | Custom claims, in-house signing, often HS256 (worst posture: multi-year exp, single shared secret across a host fleet, no refresh) |

**Auth-token-location taxonomy** (_where_ the token lives is a distinct axis from _who_ issued it — it drives the XSS-reachability read):

| Token location | Shape / retrieval tell | XSS-reachable? |
| --- | --- | --- |
| **Bearer-from-cookie** (JS-readable cookie → explicit `Authorization: Bearer`) | Non-HttpOnly cookie the SPA reads via `document.cookie` and re-attaches as a Bearer header; `credentials:'include'` with the raw cookie is **rejected** (forced by `ACAO:*`). Probe Bearer vs cookie vs raw-token to confirm. | Yes |
| **Token-in-localStorage-blob** | Token is a _field inside a JSON object_ in localStorage (`JSON.parse` then read `.access_token`), not a bare key — OIDC `oidc-client-ts` idiom. | Yes |
| **Opaque session cookie** (HttpOnly, same-origin, auto-attached) | No `Authorization` header anywhere on the SPA; the cookie auto-attaches because all API is same-origin. **The negative finding is the tell.** | No |
| **Firebase ID token in IndexedDB** | `firebaseLocalStorageDb` → `stsTokenManager.accessToken`; Firebase Modular SDK v9+ store; short-lived (3600 s) + auto-refreshed. Confirm the Bearer model with one Firestore REST `GET documents/users/{uid}`. | Partially (IDB is JS-reachable, but token is short-lived + refreshed) |

> **Same-origin replay — mine an opaque-session-cookie API without ever reading the cookie.** When the taxonomy lands on **opaque session cookie** above (no `Authorization`, no JS-readable token, no CSRF meta — a server-session app: Symfony/PHP, Rails, Django, classic ASP.NET), the _absence_ in JS storage **is** the positive fingerprint, not a dead end. Mine it with **in-page `fetch(path, {credentials:'same-origin'})` replay from the app origin**: the browser auto-attaches the httpOnly cookie, so once the in-page tap has revealed an endpoint you replay it for verbatim read bodies (and, Pass-2-gated, write bodies) **without ever reading, returning, or writing the cookie value** — the secret never reaches the harness, which is what keeps this inside the §7 ethics line. You do **not** need raw CDP `Network` for this case (it would only add the literal `Cookie`/request headers, which you'd redact anyway); the in-page fetch/XHR tap + same-origin replay is the complete tool. Reach for raw CDP only to reconstruct exact request headers for an _out-of-browser_ reproducer, or for non-`fetch` transports (WS frames). (Origin tag: Akeneo — httpOnly `BAPID` Symfony session, 54-path internal API mined entirely by same-origin replay.)

**Key claims to look for in the JWT payload** (decode _structure_ only — redact `sub`/`email`/`session_id`/`provider_id`, never log the signature):

| Claim | What it tells you |
| --- | --- |
| `iss` | Issuer URL — names the auth provider (a custom domain → enterprise tier) |
| `org_id`, `org_slug`, `org_role`, `org_permissions` | **Tenant context inside the token** — backend does RBAC from the verified JWT alone, no separate org lookup (standard with Clerk Orgs) |
| `azp` | Authorised party — typically the SPA origin |
| `fva` (Clerk-specific) | Factor verification age — gates "re-verify your password to do X" actions |
| Lifetime (`exp - iat`) | Short (≤5 min) → tight refresh, enterprise-leaning. Long (≥1 day) → loose; cookie-only or large-hop systems |

### 2.3 API-envelope / style fingerprint (response shape → backend framework)

| Style | Tells (envelope / endpoint shape) |
| --- | --- |
| **JSON-Schema-driven (Huma / similar)** | Every response carries inline `"$schema": "https://api.x.com/<TypeName>OutputBody.json"`; RFC 7807 error envelope |
| **Hand-rolled FastAPI / Express** | No `$schema` field; minimal error shape (`{detail: "..."}`) |
| **GraphQL** | Single `/graphql` endpoint, query/variables in body, often `nustack-cache-persist` storage |
| **gRPC-Web / Connect / tRPC** | `application/grpc-web*` content-type, or `/trpc/<procedure>` endpoints |
| **OData** | `$filter`, `$expand`, `$select` query params |
| **JSON-API** | `data.attributes` / `data.relationships` envelope |
| **Next.js API-route axios proxy** | Same-origin `/api/*` route handlers fronting upstream microservices. Tell: 500s carry verbatim axios string `"Request failed with status code 404"` |
| **Spring Data Pageable** (Java/Spring) | `{content, pageable, totalPages, totalElements, last, numberOfElements, size, number, sort, first, empty}` envelope |
| **ASP.NET Core CQRS/MediatR** | `{response, correlationId, isSuccess, failures[]}` envelope; may coexist with legacy raw-JSON (in-progress migration); PascalCase resource paths off root (`/User`, `/Brand`) with no `/api` prefix |
| **DRF (Django REST Framework)** | `{count, next, previous, results[]}` pagination; `@action` verb routes |
| **Firestore-as-the-API (serverless, read/write split)** | No read REST for app data — SPA subscribes to Firestore docs (WebChannel) for reads; writes are POST-only Cloud Function / RPC (often CORS-walled from the SPA origin); entitlement in a Firestore field, not token claims. Mine the Firestore IndexedDB cache (`collectionParents`/`remoteDocuments`/`targets`) instead of the network. |

> **Multi-stack envelope detection:** if one product returns multiple distinct envelope shapes across endpoints, that signals multiple upstream services with different stacks (a real run saw **6 distinct envelopes = 4–6 microservices**). Naming conventions corroborate — snake_case (Python/FastAPI) vs camelCase (Java/Spring).

### 2.4 The multi-channel-realtime trap (don't conclude after finding one)

A single product often uses **multiple** realtime channels with different roles — install **all** taps simultaneously _before_ triggering, and let each make its own claim. Two architectural patterns to name first: **content-streaming** (frames carry the actual deltas — text, edits) vs **cache-coherence-ping** (frames carry only metadata like `{job_id, step_num, function_name}`; the SPA re-fetches a REST endpoint after each ping).

**Channel-role split (the trap):** a realtime product commonly runs (1) a content/op stream **plus** (2) a REST refetch for snapshots **plus** (3) a separate presence channel — three channels, three roles. **A dashboard that is REST-only (no WS, no SSE, no long-poll) is a real architectural signal, not a miss** — record the negative.

**Streaming-protocol detection table:**

| Pattern | Tells (host / content-type / frame shape) |
| --- | --- |
| **WebSocket** | `wss://` URLs in the network panel — wrap the `WebSocket` constructor |
| **Server-Sent Events via `EventSource`** | `text/event-stream` content-type **and** `EventSource` constructor used — wrap `EventSource` |
| **HTTP long-poll, SSE-formatted body via `fetch`** | Repeated GETs with `?last_*=` / `?cursor=`; `text/event-stream` content-type but **no** `EventSource` constructor — fetch response-body tap |
| **HTTP long-poll, JSON body** | Repeated identical-URL GETs, JSON responses — fetch response-body tap |
| **WebSocket, content-streaming** | `wss://`, frames carry actual deltas |
| **WebSocket, cache-coherence-ping** | Tiny WS frames `{job_id, step_num, function_name}`; SPA refetches REST `/history` after each |
| **SSE-via-fetch, content-streaming** | `GET .../latest-message` with `text/event-stream`; held open for one message |
| **Pusher / Ably / Pubnub** | `wss://ws-*.pusher.com/...`, `realtime.ably.io/...` — WebSocket constructor wrap + library global tap; events namespaced per channel (`user-{uuid}`, `workspace-{id}`, `presence-doc-{id}`) |
| **Firestore WebChannel** | `firestore.googleapis.com/.../Listen/channel` GET-with-streamed-body, `text/plain` |
| **Firebase RTDB WebSocket** | `wss://*.firebaseio.com/.ws?ns=<project>` (often presence only) |
| **GraphQL Subscriptions** | `wss://...` with `connection_init` / `subscribe` / `next` / `complete` frames (NuStack/Hasura) |
| **gRPC-Web / Connect** | `application/grpc-web*` content-type — fetch response-body tap (binary) |
| **WS-OT/CRDT (Yjs, Automerge)** | Binary-framed WS, small + bursty frame sizes |
| **No streaming on dashboard** (per-call only) | Voice/per-session platforms: dashboard is REST-only; realtime lives only at the per-call layer (WebRTC + data channel), never visible from admin pages |
