Extension as a playhtml peer
Date: 2026-06-18 Status: Design / not yet built Owner: Spencer
The idea
Let the "we were online" extension read and manipulate the live playhtml objects on a website that intentionally runs playhtml. The author of a site places can-move / can-toggle / can-play objects; the extension can grab, read, and drive those objects from outside the page — the way a crypto wallet exposes window.ethereum to a page and bridges signed actions back through the extension.
This is a different capability from the bottles feature:
Bottles run on every page (most of which have no playhtml), store our data in our extension-owned room (key bottles, room wwo<path>), separate from the host site’s playhtml room, and need their own connection regardless of the host page. Correct as built — see “Room isolation” below for the one cursor-site gap. Peer interop runs only on pages that already host playhtml, and reaches into that page's objects and room. New capability, documented here. Why this is possible (correcting an earlier misread)
playhtml already publishes its full API on window.playhtml when a page inits it (packages/playhtml/src/index.ts:844), and sets data-playhtml="true" + fires a playhtml:ready CustomEvent specifically so an extension can detect it (index.ts:848, :914). The library was built with extension interop in mind.
The one true constraint: a normal extension content script runs in the ISOLATED world, so it cannot call window.playhtml directly (the page's window is a different realm). But that's a structural boundary, not a wall — it's the exact problem the wallet pattern solves.
How it works — the wallet pattern, two pieces
site's playhtml elements ←→ window.playhtml (MAIN world) ▲ injected MAIN-world bridge calls the API here │ ▼ postMessage / CustomEvent over the shared DOM extension content script (ISOLATED world) ←→ background / popup / UI ▲ keeps the privileged browser.* APIs
MAIN-world bridge script. Inject a script that runs in the page's world. In WXT this is world: "MAIN" on a content script (or injectScript). This script can call window.playhtml.createPageData(...), setupPlayElement(...), read elementHandlers, dispatchPlayEvent(...), etc. — it's in the same realm as the page. Cross-world bridge. The MAIN-world script and the ISOLATED content script talk over window.postMessage (or CustomEvents on the shared DOM). The ISOLATED side keeps browser.* (storage, messaging, the popup); the MAIN side keeps page-object access. This is exactly MetaMask's page ↔ injected-provider ↔ content-script ↔ background topology. Why the bridge is host/room-independent (the load-bearing property)
A site can run its own PartyKit server: playhtml.init({ host: "their.server" }) makes it connect to their host, their room, their doc — totally separate from our playhtml.spencerc99.workers.dev (index.ts:854, getPartykitHost).
The bridge does not care, because it operates above the transport. It calls window.playhtml.elementHandlers[...], getData(), setData(), etc. on the page's already-connected instance — which has already resolved whatever host / room / doc the site chose and synced it. The bridge reads live, in-memory, resolved object state; it never opens a socket, picks a host, or joins a room itself. window.playhtml.host and .roomId (index.ts:1518) even let the bridge see where the page connected, read-only, without connecting there.
This is the decisive argument for data isolation over co-mingling:
Shared-room interop only works if extension + site are on the same host AND room. The moment a site self-hosts (init({host})), that's impossible — we can't (and shouldn't) join their private server. Fragile by construction. Bridge interop works for any playhtml site regardless of host/room/server, because the page already did the connecting and we just read its API. So interop ≠ shared room. The bridge is the correct (and only host-independent) interop mechanism, which means the extension keeping its own isolated room costs nothing on interop. (It also closes a security hole: in a shared room, the site owner can read/modify/delete the extension's data, since it's their doc.)
What's reachable on window.playhtml (the API surface to bridge)
From index.ts:1482: createPageData, createPresenceRoom, presence, cursorClient, dispatchPlayEvent / registerPlayEventListener, setupPlayElement / removePlayElement / deleteElementData, elementHandlers, eventHandlers, roomId, host, ready, listSharedElements. That's enough to enumerate a page's shared elements, read / write their data, and join the page's room.
Room isolation: what shipped vs. the known gap
playhtml is a module singleton — init reassigns module-level yprovider/cursorClient/presenceAPI, so one imported playhtml = one live instance; a second init({room}) is a room change, not a second instance. And createPageData is hardwired to the instance’s main doc/room (no per-channel room, unlike createPresenceRoom which makes its own provider). Consequences:
Normal pages + native-playhtml pages (the ~99.9% case): the extension stands up its own instance in an isolated room wwo<pathname> (auto-prefixed with the page host). Bottle data is fully isolated from any site’s room. Implemented in content.ts ensureGlobalFeatures() → init({ cursors:false, room: "wwo"+pathname }); channel key bottles. Custom cursor-sites (the handful in the custom-sites list): the extension already runs an instance in the SITE’s room for cursors + link-glows, and bottles reuse that instance — so on those pages bottle data is co-mingled into the site’s room. KNOWN LIMITATION, accepted for now. Follow-up (playhtml core change): give createPageData(name, default, { room }) the ability to open its own data room/provider (mirror createPresenceRoom). Then bottles can always be in the isolated WWO room even while sharing the cursor instance, closing the cursor-site gap. Deferred until the extension↔native-playhtml-site work comes to a head.
Known limitation — SPA navigation (also a playhtml-core gap): the extension inits with an explicit room: "wwo"+pathname. playhtml caches that as explicitRoomOption and, on a client-side route change, its nav handler reuses the cached explicit string instead of recomputing from the new pathname (index.ts:704 — explicitRoomOption ?? getDefaultRoom(...); only the DEFAULT room recomputes). So on an SPA that changes pathname without a reload, bottles stay bound to the room created for the initial path. The main room option is string-only (cursors’ room can be a function; the page-data room can’t), so the extension can’t make it nav-aware on its own. Fix belongs in playhtml core: let the main room accept a function, or apply the isolation prefix inside the nav recompute. Deferred with the cursor-site gap. (Surfaced by the 2026-06-18 delta review.)
The channel key is just bottles (no wwo: prefix, no URL) — the room is already per-page, so the key only names the feature within the room (and stays distinct from siblings like link-glows on the shared cursor-site rooms).
Shipped on PR #186 (feat/social-bottles), flag-off / dev-gated.
Decisions made (2026-06-18 discussion)
Extension data is isolated in its own room, not co-mingled into the site's playhtml room. Reasons: (a) co-mingling lets the site owner read/modify/delete WWO data; (b) it breaks entirely when a site self-hosts PartyKit; (c) it buys no interop, because interop goes through the bridge, not the shared doc. link-glows: is intentionally left in the site's own room — it simulates a site (e.g. Wikipedia) running playhtml itself, which is exactly the peer-target shape. It is NOT extension-private data. Interop is via the bridge, always — never via joining the site's room. Open design questions (decide before building)
Which objects? All shared elements, or only ones the site opts into exposing (e.g. a data-wwo-expose attribute)? Opt-in is safer and gives the site author control. Read-only vs read-write. Reading object state (a "x-ray" of the page's live playhtml) is low-risk. Writing into someone else's room is a trust / griefing surface — probably gated behind explicit user action and/or site opt-in. Trust / permissions model. The wallet model has a strong consent step (the page requests, the user approves in the extension). What's our analog? Which origins are allowed? Per-site approval persisted where? Identity. The extension already injects its identity into the page's playhtml via the playhtml:configure-identity CustomEvent (content.ts:952). Peer actions should carry that identity so writes are attributable. Bridge protocol shape. Request/response with ids over postMessage; how to stream Yjs updates back to the ISOLATED side without serializing the whole doc on every change (diff-based? subscribe to specific element keys only?). Lifecycle. SPA navigations swap the page's playhtml provider (index.ts nav controller). The bridge must re-handshake on playhtml:ready re-fires. Relationship to the current bottles work
Nothing here changes the shipped bottles design. Bottles keep their own extension-owned headless instance and isolated wwo room. Peer interop is additive: a world: "MAIN" entrypoint + a bridge module, gated behind its own flag, that only activates on data-playhtml="true" pages.
Perf note (why two connections is a non-issue)
A frequent worry: "doesn't this open two sockets on a playhtml page?" The breakdown:
playhtml multiplexes. createPageData reuses the single shared Y.Doc + the one yprovider WebSocket opened at init (index.ts:590, page-data.ts uses deps.doc). All channels (bottles, tape, future experiments) ride one socket. Adding channels adds zero sockets. So the extension contributes one socket, and only on pages where a social experiment is active, and only to our own PartyKit host. On a native-playhtml page the page has its own socket — but that exists with or without the extension; it's the page author's connection, not ours. Peer interop, if it reuses the page's window.playhtml, rides the page's existing socket — it would add no new connection at all. So: the extension is one socket on active pages; peer interop is zero additional sockets. Not a perf concern.