You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Shared Sessions v0: GitHub-auth'd shared conversation panel with WebRTC transport #658
Let a Overdeck host share a single conversation with other Overdeck users via a overdeck.ai/s/<id> link. Viewers render the conversation in their own local Overdeck dashboard. Default surface is the conversation panel (with an optional terminal view toggle). Transport is WebRTC DataChannels with a tiny signaling + TURN service hosted at overdeck.ai.
This is the v0 of a larger "shared Overdeck" capability. Sharing entire agent sessions (watch-an-agent-with-friends) is explicitly out of scope and tracked as a followup.
Motivation
Overdeck is a bundled local client/server app — every user runs it on their own localhost. There's currently no way for two people on a refinement call to look at the same conversation, watch the same investigation unfold, or collaboratively shape a prompt. The natural model — "give somebody a link" — doesn't work because the data lives on the host's machine behind their NAT.
The killer use case: a refinement call where one person drives a codebase investigation in Overdeck and the rest of the team watches live, suggests prompts, and collaboratively converges on the right next step.
Locked decisions
Scope
One conversation per share link. Not the whole Overdeck, not multiple conversations, not the kanban/inspector. Just one conversation.
Conversation panel is the default surface. A more natural interface than the terminal — most people on a call don't want to watch a tmux pane.
Terminal view available as a toggle. Power users can flip to the raw tmux view of the underlying session.
Sharing existing agent sessions is out of scope for v0 — see followup issue.
Identity & auth
GitHub OAuth on overdeck.ai. Identity = GitHub login. Avatars + handles come free.
Presence list shows which GitHub users are currently viewing.
OAuth scope: read:user only. Enough for identity (login + avatar). The followup markdown editor may later want gist-backed storage; if so we request that scope then and accept a one-time re-consent. We do not ask users for write access to their gists before a feature uses it.
Access control — invite-only (hard requirement)
Invite-only is a hard requirement. Possession of the overdeck.ai/s/<id> link MUST NOT, by itself, grant access to the conversation. The link is a routing token, not a capability — every participant is in the room only because the host let them in.
Host-approval lobby. A joiner authenticates via GitHub, then lands in a waiting room. The host sees their GitHub avatar + handle and must explicitly admit them. Nobody reaches the conversation (no conversation/draft/terminal/presence channel data) without an affirmative host action.
Host manages participants live. The host (current controller — see Host role & handoff) has three actions, available at any time mid-session:
admit — move a pending joiner from the lobby into the room.
kick — soft removal of a connected viewer. Tears down their WebRTC DataChannels immediately; their UI drops to a "removed by host" state. A kicked user may re-click the link and lands back in the lobby, where the host can re-admit them. Kick is for "not right now".
revoke — hard removal. Works on a connected viewer or someone still sitting in the lobby. In addition to tearing down channels, it adds that GitHub handle to a per-session block list: a revoked user who re-clicks the link cannot re-enter the lobby at all and cannot be re-admitted without the host explicitly un-revoking them. Revoke is for "not at all". This block list is what makes the invite-only guarantee enforceable against a leaked link.
GitHub OAuth supplies identity/accountability; the lobby + block list supply authorization. Both are required — neither alone is sufficient.
Host role & handoff
"Host" decomposes into two roles. (a) Data owner — the machine that runs the conversation + the underlying tmux session and is the WebRTC hub for every viewer. Fixed for the life of the session; the conversation does not migrate machines in v0. (b) Controller — the participant who drives: admits/kicks/revokes others, and picks + sends prompt drafts.
Host handoff is supported. The controller role is transferable to any admitted viewer mid-session ("you drive for a while"). The data-owner machine stays the hub and the authority — it accepts control commands (admit/kick/revoke, send-prompt) only from whoever currently holds the controller role.
Handoff mechanics. The current controller hands the role to a chosen viewer; the data owner can always reclaim the controller role unilaterally (it is their machine and their conversation). Exactly one controller at a time.
This is why the issue models host as a role on the room, not a property of a connection.
Clients & join paths
Two first-class clients in v0: the dashboard and the CLI. Both host-share and join are supported from each.
Dashboard host: "Share this conversation" action → returns the overdeck.ai/s/<id> link.
Dashboard viewer: open the link in a browser → GitHub OAuth → localhost-detect hands off to the running local dashboard → lobby.
CLI host: pan share <conversationId> → prints the link and a short join code.
CLI viewer: pan join <link-or-code> → GitHub OAuth (device flow, like gh auth login) → lobby → on admit, the shared conversation opens in that user's local dashboard. The CLI already knows its own local dashboard port, so it bypasses localhost-detect entirely.
All join paths funnel through the same host-approval lobby — the CLI is not a back door around access control.
Link lifecycle
Link lifetime = conversation lifetime. The overdeck.ai/s/<id> link is valid for as long as the host keeps the conversation shared. It expires automatically when the session ends — the host ending or closing the shared conversation tears down the room and dies the link.
No fixed-duration expiry (no 24h timer) in v0. A session can run as long as it runs.
Manual link revoke stays available: the host can kill the whole link at any time, which drops every participant — both admitted viewers and anyone waiting in the lobby.
Per-participant revoke (a single GitHub handle) is separate and lives under Access control — kick is soft, revoke is hard.
Host disconnect & reconnect
The data-owner machine is the hub. If its network blips or its Overdeck process restarts, viewers see a "host disconnected — reconnecting…" state.
Reconnect window: 5 minutes. The room (its id, host token, admitted list, block list) is held alive by the signaling service for 5 minutes after the host drops. Host returns within the window → the session resumes; past it → the room is torn down for good and the share link dies.
Admitted viewers auto-restore on host return. Within the window they are silently reconnected — no re-admission. This holds even if the host's process fully restarted, because the admitted + block lists live in the signaling service's room state (that is membership/identity, not conversation content — the no-content guarantee is intact) and the host re-reads them on reconnect.
After a full teardown (host gone past 5 minutes) there is no auto-restore: a fresh share creates a fresh room and everyone re-joins through the lobby.
Viewers always have a manual Leave action, so a long reconnect window never traps anyone on a spinner.
Input model
View-only by default. A newly-admitted viewer cannot drive the conversation; they compose prompt suggestions in their own local draft box.
Suggestion UI: stacked list. The host sees every viewer's pending draft as a stacked list with author avatars (queue-of-questions feel) and picks one to send, optionally editing it first.
Drafts sync live as people type — plain text snapshots/diffs over the data channel. No CRDT, no fancy editor in v0. Last-write-wins per draft, scoped to the author.
Direct-submit (contributor) permission. The host can grant any admitted viewer direct-submit: that viewer's prompts post straight into the conversation without the host picking/approving each one. It is a per-viewer, host-granted, revocable permission; a viewer holding it is a contributor.
Direct-submit is not host handoff. A contributor can send prompts but cannot admit/kick/revoke or hand off control. There can be many contributors; there is still exactly one controller.
Direct-submit does not bypass the lobby. Everyone is host-admitted first (invite-only is non-negotiable); contributor is a second, separate grant applied after admission.
Followup issue covers richer collaborative markdown editing — see below.
Transport
WebRTC DataChannels with STUN-direct preferred, TURN relay fallback for symmetric NATs.
Signaling service at overdeck.ai handles SDP offer/answer + ICE candidate relay only. Signaling service does not see conversation content in the p2p path.
TURN server hosted at overdeck.ai for the ~20% of pairs that can't NAT-traverse.
Each viewer = one DataChannel to the host.
Host's outbound WS to signaling is sufficient — no port forwarding required.
Why WebRTC over a plain WS relay
A plain WS relay at overdeck.ai proxying everything ships faster but means we pay for 100% of traffic and become a centralized chokepoint for every shared session. WebRTC keeps ~80% of pairs fully p2p (we only pay TURN bandwidth for the rest) and matches how every modern collab tool works. The cost: native dependency pain (`@roamhq/wrtc` is a native Node addon, same class of finickiness as `node-pty` under Bun — see `.claude/rules/dashboard-node22-only.md`).
PAN-2565 adds task surfaces containing multiple agent sessions. Sharing such a task is a followup, but v0 must not force a rewrite to get there. Four locked commitments (full detail in the PRD's Forward compatibility section):
Share scope is a discriminated union. v0 ships only { kind: 'conversation', conversationId }; a future { kind: 'task', taskId } is an additive variant. The signaling service stores the scope opaquely.
Every content envelope carries a streamId from day one. A v0 conversation share has exactly one stream. Multi-stream shares later reuse the same channels — the envelope, not the channel, identifies the member session; the presence channel announces the stream roster.
Host persistence is keyed by room, not by conversation. The shared_sessions table uses room_id as primary key with scope_kind/scope_id columns.
The connect handshake negotiates supported scope kinds ({ v, scopeKinds }), so an old viewer meeting a future task share degrades to an explicit "your Overdeck is too old for this share type" state instead of breaking.
Related roadmap context: PAN-2566 (Traycer parity epic). If CRDT co-editing ever lands (PAN-659 lineage), it rides a newcollab channel — the v0 drafts last-write-wins semantics are never mutated in place.
Architecture
Reuse existing terminal sharing primitive
The existing terminal architecture (`src/dashboard/server/ws-terminal.ts` + `src/dashboard/frontend/src/components/XTerminal.tsx`) already gives us multi-attach for free: a raw WebSocket → `node-pty tmux attach-session`. tmux natively supports N PTYs attaching the same session and staying in sync, so the host's local server can spawn one PTY per viewer against the same session name.
For v0 we extend this: instead of (or in addition to) the local WS transport, we add a WebRTC DataChannel transport that wraps the same byte stream. The PTY plumbing on the host is unchanged. The conversation panel sharing is a parallel data channel carrying conversation events from the host's local store.
Roles
Data owner: the machine that owns the conversation + the underlying tmux session and acts as the WebRTC hub. Its local Overdeck is the source of truth. Fixed for the session.
Controller: the participant currently driving — admits/kicks/revokes, picks + sends prompt drafts. Starts as the data owner; transferable via host handoff (see Host role & handoff). The data owner can reclaim it.
Viewer: runs their own Overdeck. Renders in their own dashboard. Composes prompt drafts.
Signaling service (overdeck.ai): WebRTC handshake + TURN relay fallback. No content visibility on the p2p path.
GET joinUrl
→ GitHub OAuth
→ opens local pan
→ pan://join?room=...
──────►
WS /signal?room=...
relays SDP offer/answer + ICE
between host ↔ viewer
◄────────────────────────────────────►
WebRTC DataChannel established (STUN direct, or TURN relay)
◄══════ conversation events ══════►
◄══════ prompt draft updates ══════►
◄══════ (optional) terminal stream ═►
(signaling service drops out unless renegotiation needed)
```
Channels per connection
conversation: server-authoritative event stream from the host's local store. Full conversation history is replayed on join so a late joiner has complete context. Carried in a scoped, versioned wire format defined specifically for sharing — not the internal `PanRpcGroup` types. Host and viewer run independently-updated Overdeck versions; using an internal type as the wire format would couple them and break rendering on version skew. The wire format is an explicit allow-list of the fields permitted to cross the host→viewer boundary.
drafts: per-viewer prompt draft updates, broadcast to all participants
terminal (optional, when viewer toggles to terminal view): raw bytes from `node-pty tmux attach-session`. Strictly one-directional, host→viewer — viewer keystrokes never reach the host's shell. Read-only is locked, not optional.
presence: join/leave + viewer list
Join flow
Two v0 join paths (see Clients & join paths under Locked decisions):
Browser path — "click link → opens local pan". Locked decision: localhost-detect — the share page detects a running local Overdeck at `localhost:` and posts the join intent there (what Zoom and Slack do). Avoids OS handler registration headaches and works the same on macOS, Linux, Windows. (A `pan://` protocol handler was considered and rejected — OS-level registration is finicky cross-platform.)
CLI path — `pan join `. The CLI authenticates via GitHub device flow, then posts the join intent straight to its own local dashboard (no localhost-detect needed — the CLI already knows the port). `pan share ` is the CLI host side.
Both paths land the joiner in the host-approval lobby; nothing renders until the host admits.
Cold start — a viewer who clicks the browser link with no local Overdeck running cannot be helped by the share page directly: a web page cannot spawn a local app. The page detects "no localhost" and tells the user to run pan join <link-or-code> instead. The CLI is the cold-start fix: pan join auto-starts the local dashboard if it is not already running, then proceeds to the lobby. So the CLI is the recovery path for an installed-but-not-running viewer. A viewer with no Overdeck installed at all must install it first; a hosted read-only web view for the truly-uninstalled case is a possible followup but is out of scope for v0 — it would put conversation content on overdeck.ai and break the no-content guarantee.
Open questions to resolve before implementation
None — all resolved.
Resolved (moved to Locked decisions)
Concurrent direct-submit ordering — resolved in PRD rev 2 (FR-14): strict FIFO queue on the host; submissions arriving mid-turn are queued and flushed at turn end; presence-driven “agent is working / N queued” indicator. Auto-contributor pre-designation is a nice-to-have, out of v0.
Host handoff — yes, supported; controller role is transferable, data owner can reclaim. See Host role & handoff.
Link lifecycle — link lifetime = conversation lifetime; expires automatically on session-end. No fixed-duration timer. Host can still manually revoke the whole link, and revoke individual participants. See Link lifecycle + Access control.
Per-session block list — yes, exists as the revoke action (hard removal + block), distinct from kick (soft removal, re-admittable). See Access control.
CLI join path — v0. pan share / pan join <link-or-code> are first-class. See Clients & join paths.
Suggestion UI — stacked list of pending drafts with author avatars. See Input model.
Persistence / late joiners — full conversation history replayed on join. See Channels per connection.
Read-only terminal — confirmed read-only; the terminal channel is one-directional host→viewer. See Channels per connection.
OAuth scope — read:user only; request gist later (with a one-time re-consent) if the markdown-editor followup needs it. See Identity & auth.
Conversation event format — scoped, versioned wire format, not the internal PanRpcGroup types. See Channels per connection.
Cold-start viewer — pan join auto-starts the local dashboard; the browser path with no localhost tells the user to run pan join. A hosted web view for the uninstalled case is a possible followup, out of scope for v0. See Join flow.
Host disconnect vs. participant state — auto-restore admitted viewers within the 5-minute window (signaling service holds the membership lists); full re-lobby only after teardown. See Host disconnect & reconnect.
Out of scope (followups)
Sharing entire agent sessions (watch-an-agent-with-friends) — separate issue
Sharing kanban / inspector / other panels — v2+
Multi-conversation sharing — v2+
Rich collaborative markdown editing for prompt drafts — separate issue (see below)
Voice / video — never (use Zoom/Meet/etc alongside)
Acceptance criteria
Host can run an action ("Share this conversation") that returns a `overdeck.ai/s/` link
Link recipient lands on a page that triggers GitHub OAuth, then hands off to their local Overdeck dashboard
Viewer's dashboard renders the host's conversation in real time, including history backfill on join
Presence list shows GitHub avatars + handles of all current viewers
Viewer can compose a prompt draft; draft updates appear live in the host's UI as they type
Host can pick a viewer's draft, optionally edit it, and send it to the conversation
Viewer can toggle to terminal view and see the host's tmux session live (read-only)
Connection works through symmetric NATs via TURN relay
A joiner with a valid link receives no conversation/draft/terminal/presence data until the host admits them from the lobby (verified by e2e test — link possession alone grants nothing)
Host sees pending joiners (GitHub avatar + handle) in a lobby and can admit each one
Host can kick any connected viewer (soft): channels torn down immediately, their UI shows "removed by host"; a kicked viewer who re-clicks the link re-enters the lobby and can be re-admitted
Host can revoke a participant (hard) — whether connected or waiting in the lobby: channels torn down, and that GitHub handle is added to the per-session block list so re-clicking the link no longer reaches the lobby; host can un-revoke
Host can revoke the entire share link (all viewers, lobby and admitted, are dropped)
The share link stops working once the host ends the shared conversation (link lifetime = conversation lifetime; no fixed-duration timer)
Host can hand off the controller role to an admitted viewer; the new controller can then admit/kick/revoke and pick/send drafts, and the data owner can reclaim the controller role at any time
`pan share ` (CLI) prints a working share link + join code; `pan join ` (CLI) authenticates via GitHub device flow and lands the user in the host-approval lobby
`pan join` auto-starts the local dashboard when it is not already running
Host can grant an admitted viewer direct-submit (contributor): that viewer's prompts then post straight to the conversation; the host can revoke the grant at any time
After a host network drop, admitted viewers auto-restore without re-admission when the host returns within the 5-minute reconnect window; past the window the room tears down and re-join goes through the lobby
Conversation events cross the data channel in a scoped, versioned wire format — not raw internal `PanRpcGroup` types
Signaling service does not see conversation content (verified by the wire format / e2e test)
Implementation notes
Native dep risk: `@roamhq/wrtc` (or equivalent) is a native Node addon. Test under Node 22 only — no Bun. Add to `.claude/rules/dashboard-node22-only.md`.
No execSync: signaling client + WebRTC transport live in dashboard server code, must use `execAsync` / promises.
TURN server: pick a deployment (coturn is the standard). Will need infra work on overdeck.ai.
Build a dumb WS relay first? Worth considering as a phase 0 to validate the UX before paying the WebRTC integration cost. Decide during planning.
Transport recommendation: take this issue's own implementation note ('consider a dumb WS relay as phase 0 to validate UX') as the v0 decision — plain WSS rooms over the Overdeck Anywhere P3: relay service — outbound-only daemon, GitHub OAuth, push origin, multi-tenant front door #2356 relay. WebRTC DataChannels (+ coturn, @roamhq/wrtc native-addon risk) become a later bandwidth/latency optimization, not the v0 transport. The locked lobby/admit/kick/revoke/controller-handoff design is transport-agnostic and survives unchanged.
Planning notes from the operator orchestrator (2026-10-02), to apply on top of .pan/drafts/pan-658.md rev 2:
Hosting: Cloudflare, not Fly.io + coturn. On 2026-10-02 the operator chose Cloudflare for all overdeck.ai hosted services (see PAN-4293). Re-plan the signaling service for Cloudflare: a Worker for the HTTP endpoints, a Durable Object per room (membership, lobby, block list, controller, SDP/ICE relay over WebSocket hibernation), and Cloudflare's managed TURN service instead of self-hosted coturn. Verify current Cloudflare TURN pricing and limits at plan time and record them. overdeck.ai DNS is already on Cloudflare.
Share identity with PAN-4293. GitHub sign-in, device flow and identity tokens should come from the PAN-4293 account service (also Cloudflare), not a second OAuth implementation. If PAN-4293 is not merged yet, define the interface and sequence the work so nothing is built twice.
Deploying to Cloudflare is an operator checkpoint. Build and test locally (wrangler/miniflare); never deploy autonomously.
Operator goal, verbatim: "the operator can give someone a link to a conversation and that person can join it and talk with the AI directly." The PRD requires the joiner to have Overdeck installed (hosted web viewer is listed as out of scope, line ~593/821). Flag this explicitly in the plan as an operator decision: keep v0 install-required, or include a minimal hosted web join page. Do not resolve it silently. Talking to the AI directly maps to the contributor / direct-submit grant (FR-8); make sure the controller can grant it in one click at admit time.
Planning (2026-10-02, auto mode): this feature is larger than one reviewable plan (the finalize size lint caps a plan at 25 files and 6 subsystems), so it is split into vertical slices. PRD rev 3 (.pan/drafts/pan-658.md) stays the single design doc for all of them.
Slice
Issue
What
1
PAN-658 (this issue)
Cloudflare share service: services/share/ Worker, one Durable Object per room (lobby, admit, kick, revoke, un-revoke, contributor, controller, end, 5-minute host-reconnect alarm, SDP/ICE relay gated on admission), Cloudflare Realtime TURN credentials, signaling protocol types in @overdeck/contracts. Built and tested locally on Miniflare. Deploy is an operator checkpoint.
Signaling-privacy e2e and forced-TURN verification
Three operator decisions are recorded with defaults, not resolved silently (PRD section Operator decisions):
OD-1, install-required vs hosted web join. Default: install-required. The rev-2 privacy objection does not hold for a static page that runs the browser WebRTC viewer, because content still flows peer-to-peer. The real gap is that the account service has no web sign-in flow. See Shared Sessions v0 (slice 10): hosted web join page (operator decision) #4481.
OD-2, allowlist collision. The account service (PAN-4293) is invite-only, so a viewer can sign in only after the operator allowlists them on the admin screen. This conflicts with "give someone a link". Default: viewers must be allowlisted. The alternative is an identity-only participant grant in PAN-4293.
OD-3, transport. The July comment proposed a plain WSS relay. The October comment specifies SDP/ICE relay plus Cloudflare managed TURN. The newer comment wins, so the transport is WebRTC.
Hosting follows the operator's 2026-10-02 decision: Cloudflare Worker + Durable Objects + Realtime TURN. Verified TURN pricing: the first 1,000 GB each month is free (shared with SFU), then $0.05 per GB of egress. Per-allocation limits are about 50-100 Mbps and 5-10 kpps. Join links use share.overdeck.ai/s/<code>, because the overdeck.ai apex is served by Vercel.
Anomalies first: (1) routes.test.ts was extended under share-room-do, outside that item's listed files, to cover the upgrade routes' refusal paths. (2) Three cases the PRD table leaves open were decided in code: a host signal to an admitted but offline viewer is dropped silently; DELETE with a missing or malformed odh_ header is 401 invalid_token, not 404; a DO reply on /init other than 201/409 is 503 room_unavailable. (3) Miniflare 5's 'secret' binding takes no inline value, so the integration test binds TURN_KEY_API_TOKEN as text. (4) The named-entrypoint fallback checkpoint was not needed: the pinned Miniflare accepted the AccountRpc binding. (5) Nothing was deployed: no wrangler deploy or secret put ran (operator checkpoint). (6) CI risk: tests/integration/services/share/miniflare.test.ts runs wrangler and workerd on CI. If it fails there, check the runner's workerd binary and the services/share workspace install first; it passes locally on real workerd. (7) PAN-4293 merged to main mid-run: a merge commit resolves package.json (both services/account and services/share workspaces, both typecheck scripts) and bun.lock (only the share workspace entry added). (8) A separate chore(plan) commit records the spec lifecycle fields that pan start wrote (status/sequence/updated, all stripped by plan-integrity), and an empty commit carries Item trailers for the 30 acceptance criteria, each verified by its parent item's tests. No items skipped: 7/7 items done, 102 share tests green, npm run typecheck and npm run lint green. Delivered: services/share Cloudflare Worker (healthz, room create/delete, /s/:code landing page, host/join WebSockets), ShareRoom Durable Object over a pure room reducer (lobby, admit+contributor, kick 4010, revoke 4011/4003, unrevoke, controller handoff/reclaim, 5-minute host-reconnect alarm, end 4004), signaling types in @overdeck/contracts, frame decoder, Cloudflare Realtime TURN credentials, docs/SHARED-SESSIONS.md plus a CLAUDE.md topic row.
PRD:
drafts/pan-658.mdonoverdeck-state(rev 2, 2026-07-10)Summary
Let a Overdeck host share a single conversation with other Overdeck users via a
overdeck.ai/s/<id>link. Viewers render the conversation in their own local Overdeck dashboard. Default surface is the conversation panel (with an optional terminal view toggle). Transport is WebRTC DataChannels with a tiny signaling + TURN service hosted at overdeck.ai.This is the v0 of a larger "shared Overdeck" capability. Sharing entire agent sessions (watch-an-agent-with-friends) is explicitly out of scope and tracked as a followup.
Motivation
Overdeck is a bundled local client/server app — every user runs it on their own localhost. There's currently no way for two people on a refinement call to look at the same conversation, watch the same investigation unfold, or collaboratively shape a prompt. The natural model — "give somebody a link" — doesn't work because the data lives on the host's machine behind their NAT.
The killer use case: a refinement call where one person drives a codebase investigation in Overdeck and the rest of the team watches live, suggests prompts, and collaboratively converges on the right next step.
Locked decisions
Scope
Identity & auth
read:useronly. Enough for identity (login + avatar). The followup markdown editor may later wantgist-backed storage; if so we request that scope then and accept a one-time re-consent. We do not ask users for write access to their gists before a feature uses it.Access control — invite-only (hard requirement)
overdeck.ai/s/<id>link MUST NOT, by itself, grant access to the conversation. The link is a routing token, not a capability — every participant is in the room only because the host let them in.Host role & handoff
Clients & join paths
overdeck.ai/s/<id>link.pan share <conversationId>→ prints the link and a short join code.pan join <link-or-code>→ GitHub OAuth (device flow, likegh auth login) → lobby → on admit, the shared conversation opens in that user's local dashboard. The CLI already knows its own local dashboard port, so it bypasses localhost-detect entirely.Link lifecycle
overdeck.ai/s/<id>link is valid for as long as the host keeps the conversation shared. It expires automatically when the session ends — the host ending or closing the shared conversation tears down the room and dies the link.Host disconnect & reconnect
Input model
Transport
Why WebRTC over a plain WS relay
A plain WS relay at overdeck.ai proxying everything ships faster but means we pay for 100% of traffic and become a centralized chokepoint for every shared session. WebRTC keeps ~80% of pairs fully p2p (we only pay TURN bandwidth for the rest) and matches how every modern collab tool works. The cost: native dependency pain (`@roamhq/wrtc` is a native Node addon, same class of finickiness as `node-pty` under Bun — see `.claude/rules/dashboard-node22-only.md`).
Forward compatibility — multi-agent tasks (locked, 2026-07-10)
PAN-2565 adds task surfaces containing multiple agent sessions. Sharing such a task is a followup, but v0 must not force a rewrite to get there. Four locked commitments (full detail in the PRD's Forward compatibility section):
{ kind: 'conversation', conversationId }; a future{ kind: 'task', taskId }is an additive variant. The signaling service stores the scope opaquely.streamIdfrom day one. A v0 conversation share has exactly one stream. Multi-stream shares later reuse the same channels — the envelope, not the channel, identifies the member session; thepresencechannel announces the stream roster.shared_sessionstable usesroom_idas primary key withscope_kind/scope_idcolumns.{ v, scopeKinds }), so an old viewer meeting a future task share degrades to an explicit "your Overdeck is too old for this share type" state instead of breaking.Related roadmap context: PAN-2566 (Traycer parity epic). If CRDT co-editing ever lands (PAN-659 lineage), it rides a new
collabchannel — the v0draftslast-write-wins semantics are never mutated in place.Architecture
Reuse existing terminal sharing primitive
The existing terminal architecture (`src/dashboard/server/ws-terminal.ts` + `src/dashboard/frontend/src/components/XTerminal.tsx`) already gives us multi-attach for free: a raw WebSocket → `node-pty tmux attach-session`. tmux natively supports N PTYs attaching the same session and staying in sync, so the host's local server can spawn one PTY per viewer against the same session name.
For v0 we extend this: instead of (or in addition to) the local WS transport, we add a WebRTC DataChannel transport that wraps the same byte stream. The PTY plumbing on the host is unchanged. The conversation panel sharing is a parallel data channel carrying conversation events from the host's local store.
Roles
Signaling protocol (sketch)
```
Host Service Viewer
POST /sessions
{ conversationId, ttl, mode }
──────►
creates room, returns
{ roomId, hostToken, joinUrl }
◄──────
WS /signal?token=hostToken
──────►
(host parks, waits)
WebRTC DataChannel established (STUN direct, or TURN relay)
◄══════ conversation events ══════►
◄══════ prompt draft updates ══════►
◄══════ (optional) terminal stream ═►
(signaling service drops out unless renegotiation needed)
```
Channels per connection
Join flow
Two v0 join paths (see Clients & join paths under Locked decisions):
Browser path — "click link → opens local pan". Locked decision: localhost-detect — the share page detects a running local Overdeck at `localhost:` and posts the join intent there (what Zoom and Slack do). Avoids OS handler registration headaches and works the same on macOS, Linux, Windows. (A `pan://` protocol handler was considered and rejected — OS-level registration is finicky cross-platform.)
CLI path — `pan join `. The CLI authenticates via GitHub device flow, then posts the join intent straight to its own local dashboard (no localhost-detect needed — the CLI already knows the port). `pan share ` is the CLI host side.
Both paths land the joiner in the host-approval lobby; nothing renders until the host admits.
Cold start — a viewer who clicks the browser link with no local Overdeck running cannot be helped by the share page directly: a web page cannot spawn a local app. The page detects "no localhost" and tells the user to run
pan join <link-or-code>instead. The CLI is the cold-start fix:pan joinauto-starts the local dashboard if it is not already running, then proceeds to the lobby. So the CLI is the recovery path for an installed-but-not-running viewer. A viewer with no Overdeck installed at all must install it first; a hosted read-only web view for the truly-uninstalled case is a possible followup but is out of scope for v0 — it would put conversation content on overdeck.ai and break the no-content guarantee.Open questions to resolve before implementation
None — all resolved.
Resolved (moved to Locked decisions)
Concurrent direct-submit ordering— resolved in PRD rev 2 (FR-14): strict FIFO queue on the host; submissions arriving mid-turn are queued and flushed at turn end; presence-driven “agent is working / N queued” indicator. Auto-contributor pre-designation is a nice-to-have, out of v0.Host handoff— yes, supported; controller role is transferable, data owner can reclaim. See Host role & handoff.Link lifecycle— link lifetime = conversation lifetime; expires automatically on session-end. No fixed-duration timer. Host can still manually revoke the whole link, and revoke individual participants. See Link lifecycle + Access control.Per-session block list— yes, exists as the revoke action (hard removal + block), distinct from kick (soft removal, re-admittable). See Access control.CLI join path— v0.pan share/pan join <link-or-code>are first-class. See Clients & join paths.Suggestion UI— stacked list of pending drafts with author avatars. See Input model.Persistence / late joiners— full conversation history replayed on join. See Channels per connection.Read-only terminal— confirmed read-only; the terminal channel is one-directional host→viewer. See Channels per connection.OAuth scope—read:useronly; requestgistlater (with a one-time re-consent) if the markdown-editor followup needs it. See Identity & auth.Host disconnect timeout— 5-minute reconnect window. See Host disconnect & reconnect.Conversation event format— scoped, versioned wire format, not the internalPanRpcGrouptypes. See Channels per connection.Cold-start viewer—pan joinauto-starts the local dashboard; the browser path with no localhost tells the user to runpan join. A hosted web view for the uninstalled case is a possible followup, out of scope for v0. See Join flow.Host disconnect vs. participant state— auto-restore admitted viewers within the 5-minute window (signaling service holds the membership lists); full re-lobby only after teardown. See Host disconnect & reconnect.Out of scope (followups)
Acceptance criteria
Implementation notes