A live mirror that is cheap, exact, and addressable
When an AI agent drives a browser, the human supervising it needs a live view of what the agent is doing. PhantomStream streams the DOM itself instead of pixels. It captures the page once as a style-inlined snapshot, assigns stable identifiers in a WeakMap, then watches the page with a MutationObserver and sends only small diffs (add, rm, attr, text) keyed by node ID and batched to the page's own paint cadence.
The payoff is a mirror whose bandwidth tracks how much the page actually changes rather than the frame rate. Text renders natively at any resolution, and remote control targets real elements by their stable IDs instead of guessing at coordinates.
PhantomStream began as milestone v0.9.9.1 "Phantom Stream" inside FSB, where it powers the dashboard's live preview of automated browsing sessions. This repository turns it into a standalone plug-and-play framework, an SDKFSB plugs back in, and the working repository for an accompanying research paper.
The problem with pixels
The obvious tools for a live view are video. WebRTC, a CDP screencast, or a sequence of screenshots all send pixels. Pixels are heavy. Every frame costs bandwidth whether the page changed or not, encoding and decoding add latency, and the result is lossy and resolution bound.
Worse, pixels are opaque. The watcher cannot ask which element the agent is touching, cannot highlight a node, cannot annotate the view, and cannot reliably drive the page back. Remote control over a video stream means clicking pixel coordinates against a frame that may already be stale, so a misaligned click lands on the wrong thing.
Why DOM streaming instead of video
Streaming structured DOM rather than frames changes the economics and the capabilities of the mirror across every axis that matters for supervising an agent.
A four-stage pipeline
The page is captured, the host wraps each message for transport, a relay fans messages out to viewers, and the viewer rebuilds and applies them. Remote control runs the same path in reverse.
Page
Snapshot: clone, inline styles, stamp node IDs, then rAF-batched diffs
Host
LZ-string envelope, session stamping, watchdog
Relay
WebSocket fan-out, 1 MiB cap, backpressure drop
Viewer
Sandboxed iframe, diff apply by ID, overlays, scale-to-fit
Core mechanisms
Stable node identity
Capture owns identity in a WeakMap<Element, string> and emits nodeIds sidecars with snapshots and add ops, so a late diff always lands on the right element without mutating the live page.
Curated computed-style capture
Roughly 85 visual-fidelity CSS properties are inlined per element rather than all 300+, with default-value elision. This took a YouTube serialize from 45 seconds down to interactive.
Display-matched diffing
Mutations batch and flush on requestAnimationFrame, so the mirror updates at the same cadence the page paints.
Session identity
Every message carries a streamSessionId and a snapshotId. The viewer rejects stale messages, so late diffs from a previous page can never corrupt the mirror.
What you get out of the box
The capture core is plain JavaScript that injects as a content script, an addInitScript, or a bookmarklet, with no runtime build step. Capture, viewer, and relay all talk through a small transport seam.
Stable node IDs
Diffs and remote-control actions address nodes by WeakMap identity, never by coordinate or fragile selector.
Curated style inlining
About 85 fidelity-critical CSS properties per element with default elision, keeping heavy pages interactive.
Paint-cadence diffs
One compact op per real change, flushed on requestAnimationFrame at the page's own update rate.
Budgeted snapshots
Snapshots stay under the relay's per-message cap by dropping offscreen subtrees on whole-element boundaries.
Dual watchdogs
A capture-side timer and a host-side alarm independently recover a wedged stream by forcing a flush or fresh snapshot.
Sandboxed rendering
The viewer rebuilds inside an iframe sandboxed to exactly allow-same-origin (never allow-scripts) with CSP and a post-parse scrub.
Privacy masking
blockSelector, maskTextSelector, and mask functions redact sensitive content before it leaves the page. Passwords always masked.
Media by reference
Images, video, and audio load from their source URL in the viewer's own browser, so media bytes never cross the relay.
Playwright adapter
Drop the capture core into a Playwright or CDP page through a ready-made adapter, with authorized reverse remote control.
The embed security contract
PhantomStream renders attacker-influenced HTML with script execution disabled by construction. Serialization strips dangerous content on the wire clone only. The live page is never touched.
- Sandbox is exactly
allow-same-origin, neverallow-scripts on*handlers, dangerous URL schemes,srcdoc, andobject/embedstripped- Private text and form values masked capture-side before transport
- Fail-closed media fetch: https-only, private-range deny, no-referrer
Sandbox guarantees
Capture, mirror, relay
PhantomStream exposes one subpath per stage. Wire a capture in the page context, a viewer in the remote context, and a relay on Node to fan messages between them.
import { createCapture } from '@full-self-browsing/phantom-stream/capture';
import { createWebSocketTransport } from '@full-self-browsing/phantom-stream/transport/websocket';
const transport = createWebSocketTransport({
url: 'wss://relay.example.com/ws?room=ROOM&role=source',
role: 'source'
});
const capture = createCapture({
transport,
skipElement: (el) => el.id === 'my-own-overlay' // exclude your own UI
});
capture.start(); // snapshot once, then stream diffsimport { createViewer } from '@full-self-browsing/phantom-stream/renderer';
import { createWebSocketTransport } from '@full-self-browsing/phantom-stream/transport/websocket';
const transport = createWebSocketTransport({
url: 'wss://relay.example.com/ws?room=ROOM&role=viewer',
role: 'viewer'
});
const viewer = createViewer({
container: document.getElementById('mirror'),
transport
});
viewer.on('state', (e) => console.log('viewer is', e.state));
// connecting | live | stale | disconnectedimport http from 'node:http';
import { createRelay, createWebSocketRelayBackend } from '@full-self-browsing/phantom-stream/relay';
const relay = createRelay(); // 1 MiB per-message cap, backpressure drop
const server = http.createServer();
createWebSocketRelayBackend({ server, relay, path: '/ws' });
server.listen(8787);
// clients join with ?room=<id>&role=source|viewerOr run it straight from the repository: npm run demo mirrors a source tab into a viewer tab, and npm run demo:playwright drives a page while a viewer mirrors it.
Documentation
The deeper treatment lives alongside the source. Each document is self-contained.
Quickstarts
Loopback, WebSocket, Playwright/CDP, extension MV3, bookmarklet, CSSOM mode
Architecture
End-to-end capture, transport, relay, and renderer pipeline plus limitations
Security
Threat model, sanitization, masking, CSP, and sandbox guarantees
Design history
How the system evolved, what failed, and why the current shape won