Browser Capture
Polylogue can receive browser-observed LLM sessions through a local-only Manifest V3 extension.
Start the receiver:
polylogued browser-capture serve
For normal long-running local service use, polylogued run starts the browser
capture receiver together with live source watching.
The receiver listens on 127.0.0.1:8765 by default and accepts the route contracts in polylogue/browser_capture/route_contracts.py:
GET /v1/status->BrowserCaptureReceiverStatusPayloadGET /v1/archive-state?provider=chatgpt&provider_session_id=...->BrowserCaptureArchiveStatePayloadPOST /v1/browser-captureswithBrowserCaptureEnvelope->BrowserCaptureAcceptedPayloadorBrowserCaptureErrorPayload
/v1/archive-state reports archive visibility, not just receiver spool
presence. Its state/lifecycle field is one of:
| State | Meaning |
|---|---|
missing |
No receiver artifact, raw acquisition row, or indexed session was found. |
spooled_only |
The receiver has a local artifact, but live ingest has not acquired it into source.db. |
ingest_pending |
source.db.raw_sessions has the capture, but index.db.sessions is missing it or has no messages yet. |
archived |
The capture has raw evidence, an indexed session, and at least one indexed message. Only this state sets captured: true. |
stale |
The receiver spool artifact is newer than the indexed archive row for the same provider session. Keep the daemon running; convergence should advance this without a manual repair command. |
failed |
The receiver artifact is unreadable or raw validation/parsing recorded a failure. |
The payload includes bounded archive evidence (raw_row_exists, raw_id,
indexed_session_exists, indexed_session_id, indexed_message_count) and a
relative artifact_ref. It must not expose absolute paths. Deployment smoke
uses this endpoint as an invariant check: a receiver that says captured: true
without raw/index/message evidence is considered broken, not merely stale.
Inspect the receiver target directly with polylogued browser-capture status,
include it in the daemon component summary with polylogued status, or include
the same component status in archive health output with polylogue ops doctor --daemon.
Control-plane browser boundary
Browser capture has a local receiver and an unpacked extension, but it does not require Polylogue to borrow the operator's authenticated browser. For web-shell or extension debugging, keep these paths distinct:
- an agent-private Chrome/MCP browser can inspect the local workbench when the local control plane provides one;
- the operator's live browser/cookies are used only after explicit approval and should be copied into an ignored local profile, never into CI or cloud agents;
devtools workspace deployment-smoke --browserlaunches a fresh headless Chrome/Chromium profile and reports the executable it resolved.
The deployment fallback is useful for proving that the deployed daemon can serve the web root to a real browser engine:
devtools workspace deployment-smoke --browser --browser-executable "$(command -v google-chrome)"
That smoke does not certify private MCP browser launch, extension ids, authenticated ChatGPT/Claude.ai pages, or copied-profile cookies.
Accepted captures are typed browser-capture envelopes and are written
atomically under the configured capture spool at <provider>/...json.
The filename is deterministic from provider and provider session id, so repeated
observation of the same web session replaces the same source artifact. Receiver
responses expose this artifact as an artifact_ref relative to the spool root;
absolute filesystem paths stay inside the receiver process.
An accepted response also carries content_hash, the SHA-256 of the exact
UTF-8 JSON request bytes. The receiver returns it only after the atomic spool
write succeeds. Background backfill therefore treats a response as a durable
ACK only when both X-Request-ID and the expected content hash match.
Every receiver response carries X-Request-ID. If the extension or a local
debug probe sends a safe X-Request-ID header, the receiver echoes its
sanitized value; otherwise it generates one. Receiver logs use the same id for
origin rejection, token rejection, malformed payloads, write failures, accepted
captures, and request timing. During branch-local extension work, copy that id
from the browser network panel or curl output into the run-local daemon log to
connect UI action, receiver decision, artifact ref, and duration.
The extension popup records a redacted debug log for the same lifecycle. Each entry is timestamped and carries stage, method/path, request id, receiver request id, provider/session identity, archive state, and error metadata where available. It does not retain transcript text, raw provider payloads, message bodies, or turn arrays. Operators should export this packet when debugging capture behavior; it is the intended bridge between visible popup state, service-worker requests, receiver responses, and daemon convergence evidence.
The popup refreshes status automatically on open and while it remains open. Manual Check status is only an explicit refresh. Button controls expose busy/success/failure states so a click has visible feedback even when archive convergence takes several seconds.
The extension lives in browser-extension/ and can be loaded unpacked in
Chrome. It includes ChatGPT and Claude.ai provider adapters, a popup control
panel, receiver configuration, current-page capture controls, badge state, and
archive-state feedback. Provider adapters should prefer provider-native
structured page/app payloads where available and use DOM text extraction only
as a compatibility fallback. The shared envelope carries session, turn,
attachment, provenance, and provider metadata semantics.
Background inventory delta
An explicitly started background job uses a ChatGPT or Claude.ai provider adapter with four operations: inventory enumeration, native fetch, response classification, and normalized capture. IndexedDB stores the inventory cursor, queue state, attempts, eligibility deadline, lease owner/expiry, fidelity, submitted envelope, and receiver receipt. MV3 alarms resume eligible work after service-worker termination; expired leases return to their prior state.
Provider HTTP runs through a strict first-party page broker. The service worker
retains durable scheduling, budgets, and receiver ACK ownership, while an
existing or inactive provider tab supplies ephemeral authenticated context.
ChatGPT access-token and selected-account values never leave MAIN world;
Claude requests must match the organization selected by the UI. Only fixed
inventory and native-conversation operations are allowed. Timeouts,
response-size limits, auth/challenge classification, and response-shape drift
fail closed; HTTP 200 without proven page context is not accepted as an empty
inventory.
Claude jobs pin their initial UI-selected organization so a later account
switch cannot mix one organization's inventory with another organization's
native conversations. A selected_organization_stale pause requires cancelling
the old job and starting a new one; Resume is appropriate only after restoring
the original selection.
The default provider concurrency is one. Token cadence is conservative and
learned upward after throttling. Retry-After is authoritative, retry delays
use exponential full jitter, and repeated 429/403/challenge or transport
failures open a fail-paused circuit. Queue size, captures per wake, and daily
request count are bounded. Receiver downtime never marks an item complete and
does not force a second provider fetch: the native-full envelope waits durably
for an idempotent receiver ACK.
An IndexedDB execution lease serializes inventory and native-fetch requests as
well as queue ownership. The coordinator atomically reserves provider budget
before network I/O; request timeouts are shorter than the lease. Pause/cancel
increments the job generation and transactionally cancels queue entries, so a
late response cannot restore stale running state. Successful ACK finalization
atomically updates job, queue, and the provider-native revision ledger. A later
job skips only an exact trusted (provider, native id, updated_at) revision;
records without a revision are fetched. Stored-envelope bytes, receiver retry
attempts, and actual IndexedDB quota errors all fail paused rather than growing
without bound.
This workflow repairs gaps relative to an immutable export/GDPR baseline and a user-selected cutoff. It honors provider authentication and controls and cannot prove completeness beyond the authenticated inventory returned by the provider. It never bypasses anti-bot checks, discovers deleted/ephemeral records absent from inventory, or activates an operator foreground tab. ChatGPT enumeration traverses all active/starred/archived flag partitions and deduplicates repeated native ids in the durable queue.
Dataflow and boundary
ChatGPT/Claude.ai page/app state
-> extension provider adapter (`browser-extension/src/content/*.js`)
-> provider-neutral `BrowserCaptureEnvelope` (`browser-extension/src/common.js`)
-> extension service worker POST `BrowserCaptureEnvelope`
-> local receiver `POST /v1/browser-captures`
-> typed Pydantic validation (`polylogue/browser_capture/models.py`)
-> atomic source artifact write under the browser-capture spool
-> live source watcher/parser (`polylogue/sources/parsers/browser_capture.py`)
-> canonical archive session/message/attachment rows
The receiver is a capture ingress, not the web workbench API. The daemon status
API only reports component readiness and spool location; it does not accept raw
browser-capture payloads. Route metadata lives in
polylogue/browser_capture/route_contracts.py so tests and future OpenAPI/web
surfaces do not infer receiver DTO or auth semantics from handler branches.
Provider payloads and coalescing
Browser capture is an acquisition path for the same provider sessions that GDPR/Takeout-style imports already store. It must not create a duplicate session merely because the evidence arrived through an extension.
| Provider/path | Adapter or source | Stored payload | Parser path | Coalescing key |
|---|---|---|---|---|
| ChatGPT authenticated page | chatgpt-native-v1 |
Full /backend-api/conversation/<id> JSON under raw_provider_payload |
delegated to the normal ChatGPT parser | chatgpt-export:<conversation_id or id> |
| ChatGPT authenticated page fallback | chatgpt-dom-v1 |
Visible turns in the browser-capture envelope | browser-capture DOM parser | chatgpt-export:<url /c/<id>> |
| ChatGPT GDPR/export file | provider import | Export JSON mapping payload |
normal ChatGPT parser | chatgpt-export:<conversation_id or id> |
| ChatGPT shared-link helper | standalone public-share script | React Router share stream reduced to export-shaped messages | separate conversion input, not the extension payload | provider-native share conversation id when converted |
| Claude.ai authenticated page | claude-ai-native-v1 when the conversation API response is observed; claude-ai-dom-v1 fallback |
Full /api/organizations/.../chat_conversations/<id> JSON under raw_provider_payload; otherwise visible turns in the browser-capture envelope |
delegated to the normal Claude.ai parser for native payloads; browser-capture DOM parser for fallback | claude-ai-export:<uuid or url /chat/<id>> |
| Claude.ai GDPR/export file | provider import | chat_messages payload |
normal Claude.ai parser | claude-ai-export:<uuid> |
The ChatGPT content script hooks same-origin fetches for authenticated
conversation JSON and keeps only a bounded recent window in page memory. On
capture it sends that provider-native JSON as raw_provider_payload; the
browser-capture parser then delegates to the same ChatGPT parser used by direct
imports. This is the preferred path because it preserves branches, current
node, message ids, timestamps, model metadata, attachments, and other fields the
visible DOM cannot reliably expose.
DOM extraction is a compatibility fallback for pages where no provider-native payload has been observed yet. It still uses the provider-native conversation id from the URL, so a later native capture or GDPR import for the same conversation updates the same archive session instead of creating a second visible session. Fallback DOM captures are therefore acceptable as temporary live evidence, but not as a reason to prefer DOM over a clean provider payload.
Temporary chats are a typed browser-capture session property, not only a
provider metadata convention. The extension writes
session.session_kind = "temporary" when the page URL or provider-native
payload identifies a temporary conversation, and the parser persists the
existing capture:temporary-chat ingest flag from that typed field. Legacy
captures that only carry provider_meta.session_kind = "temporary" remain
accepted for already-spooled artifacts.
The Claude.ai content script also hooks same-origin conversation fetches and
uses native payloads when the response is the current
/chat_conversations/<id> JSON shape with chat_messages. DOM extraction
remains a fallback for pages where the provider response is not available to
the content script.
Local auth and origin policy
Default CORS is extension-only: chrome-extension://*. Remote web origins such
as https://chatgpt.com and https://claude.ai are not accepted by default; the
extension service worker sends requests from its own extension origin after the
content adapter builds the envelope. Extra web origins require an explicit
receiver auth token. This keeps a normal web page from writing local capture
artifacts or reading receiver state merely because it is open in the browser.
If the receiver is unavailable, the extension surfaces an offline state instead of dropping content silently.
Branch-local extension proof modes
Use devtools workspace dev-loop when changing the receiver, extension, or
provider adapters from a branch. The branch-local loop owns distinct proof
levels, from cloud-safe synthetic checks to local workstation evidence:
--extension-smokeimports the real background worker with a Chrome API mock and proves receiver auth rejection, receiver status, and accepted capture writes without a GUI browser.--browser-provider-smokeloads the unpacked extension into headless Chrome/Chromium, maps deterministic ChatGPT and Claude fixture pages onto their real supported origins, and proves content-script capture plus receiver request-id/artifact evidence without cookies.--browser-live-proofis explicit operator-local evidence for authenticated copied-profile work. It opens a visible Chrome/Chromium with an operator-approved copied user-data-dir, live ChatGPT/Claude conversation URLs, and the unpacked extension; it writes a redacted proof summary and keeps any raw captured content inside ignored local receiver spool artifacts.
For a non-authenticated Chrome-family binary check, --browser-smoke sits
between those two layers: it loads the unpacked extension into a fresh headless
profile and proves that the MV3 service worker can talk to the branch-local
receiver. It still does not exercise provider cookies or live pages.
Use docs/visual-evidence.md for the repo-owned reader
DOM evidence lane and docs/dev-loop.md for the full
branch-local browser proof ladder. Design screenshots or store media are not
accepted as browser-capture verification unless they are tied to one of those
commands and a run-local artifact.
Generate the copied-profile checklist first:
devtools workspace dev-loop --browser-plan
Then run the live proof only from a local workstation with a copied profile:
devtools workspace dev-loop --browser-live-proof \
--browser-live-profile-dir .local/browser-profiles/<run-id>-chrome-user-data \
--browser-live-chatgpt-url https://chatgpt.com/c/<conversation-id> \
--browser-live-claude-url https://claude.ai/chat/<conversation-id>
The live proof refuses CI by default and rejects common live profile roots or Chrome singleton lock files unless a local operator explicitly overrides the guardrail. Summaries redact source URLs and provider session ids, omit raw turn text, and record provider/adapter identity, role coverage, receiver request ids, and spool artifact refs.
Current residual map for #1824 / #1847
Fixed in this slice: default web origins no longer cross the local receiver boundary unauthenticated; receiver routes have a small executable contract; valid and malformed receiver payloads are tested at the HTTP boundary; accepted and error DTOs carry receiver/schema/source identity; accepted and archive-state DTOs use bounded artifact refs instead of local filesystem paths.
Still outside this slice: browser-capture artifacts still enter the archive as
provider sessions (chatgpt, claude-ai) rather than a distinct acquisition
source family; the daemon web API exposes status/read surfaces but not a full
web workbench flow; extension-id pinning is still operator policy rather than a
default because unpacked extension ids are local-install specific.