Phone–TV Companion Mode
Architecture for running a patient session across two devices: the phone as the control surface (auth, prep, controls, pose camera, feedback) and the TV as a pure presentation surface (exercise videos + cue overlays). Supersedes the v0 "smart-TV standalone" fallback section of the Patient Session Player.
Layer 10 — Telerehabilitation
Companion mode is an extension of the patient session player. It does not replace the player; it splits the player surface across two devices and adds a session-scoped channel between them.
As-built protocol (v2, 2026-06-11) — read this first
The shipped implementation moved the conductor to the phone (CompanionConductor, apps/portal/lib/session/companion-conductor.ts — mirrors the kiosk SessionConductor's snapshot contract); the TV (apps/tv/) is a dumb renderer with no conductor and no auto-advance of its own. Where a section below disagrees, the code wins:
- Commands (phone → TV,
POST /v1/session-runs/{id}/commands):session_payload(carriesconductor:"phone"+session_nameresolved to the patient's locale +localeitself — the TV is org-less, so the phone resolves locale on its behalf and the TV'si18n.jsswitches its chrome strings + exercise-name envelope resolution; server-retained + replayed onchannel-tvconnect, and the phone retries publish until the firsttv_state),play_instructions {idx},show_phase {phase: getready|between, seconds, exercise_idx},load_exercise {idx},pause,resume,end_session,release_tv(post-feedback → TV returns to the pairing screen; 3-min TV-side fallback timer).skip_exercise/skip_restsurvive only for legacy (flag-less) phones. - tv_state (TV → phone, 1Hz):
conductor_status,current_exercise_idx(the conductor's staleness gate),exercise_video_time_s,video_kind: exercise|instructions,video_ended(the advancement signal),phase. - Sequencing (all phone-side): instructions clip (direct Baseline MP4 from
instructions_tv_mp4_urls; clip skipped when null) → 3s get-ready → exercise video →rest_after_exercise_scountdown → … → done. The TV's countdown cards are cosmetic; only the phone's next command advances anything. - Clinical record: the phone fires the same Design C milestones as the kiosk (shared
use-milestone-emitterhook) —ended_naturallyflips server-side on the last terminal milestone.tv_done/tv_errorevents were never built and are not part of the protocol. - End of session: natural done / explicit end →
end_session→ TV holds a done card with its SSE open → feedback submit sendsrelease_tv→ fresh pairing code.
Why this exists
Logging in on a smart TV with a remote is painful. Navigating multi-step prep (safety → materials → pose-opt-in) on a TV is painful. Filling text feedback on a TV is effectively impossible. The phone is already the patient's primary device — it has auth, the camera, and a real keyboard. The TV's only job that the phone does worse is "be a big screen the patient can watch from a couch / mat."
So: TV plays the exercise videos. Phone does everything else. No login on TV, no navigation on TV, no input on TV. Ever.
Roles
| Concern | Phone | TV |
|---|---|---|
| Clerk auth | ✅ | ❌ (display-token only) |
Run ownership (run_id) | ✅ | (knows it via channel) |
| Prep flow (safety / materials / pose-opt-in) | ✅ | ❌ |
| Exercise loop playback | ❌ (default) | ✅ |
| Conductor state machine | ✅ (CompanionConductor — see the as-built banner) | ❌ |
| Pose-tracking camera + MediaPipe | ✅ | ❌ |
| Patient self-view (camera mirror) | ✅ | ❌ |
| Progress strip + session controls | ✅ | ❌ |
| Pain reporting | ✅ | ❌ |
| Skip / pause / end-session commands | ✅ | ❌ |
| Post-session feedback (VAS / RPE / notes) | ✅ | ❌ |
POST /v1/session-runs/{run_id}/* | ✅ | ❌ |
POST /v1/pose/frames | ✅ | ❌ |
POST /v1/media/events | ✅ | ❌ |
POST /v1/session-runs/{run_id}/tv-liveness | ❌ | ✅ |
The "default" qualifier is intentional: when companion mode is not used (patient elects to run the session on phone alone), the phone takes on all the TV-side responsibilities too — that's the existing kiosk path, unchanged.
Flow
Pairing direction: TV generates the code, phone claims it. Typing on a TV remote is the worst input device in the home; typing on the phone is ~30× faster. The TV does not authenticate; it just declares "I am a display, waiting for a session." The phone, already authenticated, picks the session and binds itself to the waiting TV.
PHONE SERVER TV
───────────────── ───────────────── ─────────────────
restartix.tv lands
│
▼
POST /v1/session-pairings
│
◄── { pair_id, code,
expires_at }
│
GET /v1/session-pairings/{pair_id}/channel
(anonymous SSE; awaits "paired")
│
▼
[ shows code "123-456"
+ scannable QR ]
/sessions (picker — assignments + active runs)
│ Tap "Începe" on an assignment
│
│ (Phone holds source_kind + source_id from the picker fetch; no
│ /v1/session-runs POST here yet — TV-path defers run creation
│ to the claim. Phone-only path fires startPhoneRunAction on the
│ where-to-play "Pe telefon" tap.)
▼
/sessions/{sessionId}/start/safety (existing kiosk prep on phone)
│
▼
/sessions/{sessionId}/start/materials
│
▼
/sessions/{sessionId}/start/pose-opt-in
│
▼
/sessions/{sessionId}/start/where-to-play → "Pe TV"
│
▼
/sessions/{sessionId}/start/pair-tv [ manual code entry; QR scan future ]
│
│ Patient scans QR or types code displayed on TV
▼
POST /v1/session-pairings/{pair_id}/claim
body: { code, source_kind, source_id, idempotency_key } (Clerk-auth'd, phone-side)
│ │
│ server: validate code,
│ CREATE session_runs row,
│ link pairing ↔ run_id,
│ mint display_token,
│ emit "paired" on pair channel
│
│ ← { ok } ─── "paired" event ───►
│ { run_id,
│ display_token, ttl_s,
│ patient_name,
│ session_name,
│ session_payload }
│ │
│ ▼
│ TV: close pair channel;
│ open run channel with
│ Bearer display_token;
│ show "Maria · Ziua 14 —
│ Mobilizare" ready card
│ for ~5s; then start.
▼ │
/sessions/{run_id}/run/companion ▼
│ [ exercise loop ]
│ channel: SSE both ways │
│ │
[ companion UI ] ◄── tv_state(idx, time, status) ~4Hz ─────────────────│
│ ─── command(pain | skip | end) ───────────────────────────────► │
│ │
│ end of last exercise
▼ tv_done │
/sessions/{run_id}/run/feedback [ "Done!" card on TV
channel closes ]
│
▼ submit
POST /v1/session-runs/{run_id}/feedback # post-terminal only — status
# was already flipped to
# ended_naturally by the last
# exercise's terminal event
│
▼
/sessions/{run_id}/run/doneThe phone's prep flow (safety → materials → pose-opt-in) is unchanged from the existing kiosk path. The companion split is added as a branch after pose-opt-in completes, before the exercise loop starts. This keeps the just-landed work intact and adds the TV path as an extension.
The TV can be opened to restartix.tv at any point — before the phone-side prep starts, during prep, or only after the patient picks "Pe TV." The pair_session sits idle on the TV until the phone claims it (or until its 1-min TTL expires, at which point the TV auto-refreshes for a new code).
Pairing protocol
Code generation (TV)
POST /v1/session-pairings (unauthenticated — TV cannot have credentials at this point)
// request: empty
// response:
{
pair_id: string; // opaque uuid, used by TV to subscribe to its pre-pair channel
code: string; // 6 digits as a string (e.g. "123456"); TV may render visually grouped ("123-456") for legibility
expires_at: string; // ISO 8601, +1 min from creation
}Server creates a row in session_pairings (state, not events — short-lived rows swept by a background job after expires_at + 1h). Rate-limit: 5 per minute per IP, 50 per hour per IP. No body required; nothing identifying flows in.
Code semantics:
- One-shot — first successful claim invalidates the code; second
pair/claimreturns 409. - Pre-run — code is bound to a
pair_id, not arun_id. Therun_idis attached at claim time. (This is the load-bearing change vs. an earlier draft.) - TTL 1 min — if not claimed in time, the TV auto-calls
pair/createagain and re-renders. Old code becomes permanently invalid. - Charset — 6 digits (
0-9). The TV may render the displayed code with a visual separator ("123-456") for couch-distance legibility; the wire + DB never carry the separator. The phone lookup/claim handlers strip non-digits before validating length, so the patient can type with or without the separator. - Never logged — pairing codes do not appear in server logs, audit log, CloudWatch, or any client-side telemetry. Implementation MUST install a structured-logger redaction rule for any
codefield on apair/*endpoint. Within their TTL window, codes carry the same authority as a session-scoped bearer token; treat them as secrets.
The TV opens an anonymous SSE on GET /v1/session-pairings/{pair_id}/channel and waits for one of:
- A
pairedevent (claim succeeded — see below). - A
expiredevent (TTL elapsed; TV auto-refreshes).
Claim (phone)
POST /v1/session-pairings/{pair_id}/claim (Clerk auth — phone is authenticated)
// request:
{
code: string;
source_kind: "treatment_plan_session" | "session" | "guided_program_session";
source_id: string;
idempotency_key: string; // client-generated uuid; re-claim with same key is idempotent
}
// response (200):
{ ok: true }
// response (404): unknown / expired code
// response (409): code already claimed (different idempotency key)
// response (403): patient doesn't have access to source_kind/source_id at current orgThe claim is load-bearing for run creation in the TV path — the server creates the session_runs row as part of this call, NOT in a separate pre-create POST. This avoids the orphan-run failure mode (run exists in DB but never gets paired to a TV; would otherwise need a cleanup job). Phone-only flow stays as it was — POST /v1/session-runs on Start tap creates the run independently.
Server-side sequence:
- Validate
codeagainstsession_pairingsrow — not expired, not already claimed (or, if claimed with sameidempotency_key, return 200 idempotently). - Validate caller's access to
source_kind+source_idat the current org (RLS + permission check viasession_runs.run). - Insert
session_runsrow withstatus = 'in_progress', returningrun_id. - Update
session_pairingsrow:run_id = X,claimed_at = now(),claimed_by_principal_id = current. - Mint
display_tokenJWT (claims:run_id,org_id, scope, exp). - Emit
pairedevent on/v1/session-pairings/{pair_id}/channelcarrying:
{
type: "paired";
run_id: string;
display_token: string;
ttl_seconds: number; // session.estimated_duration_s + 1800
patient_name: string; // first name only, for confirmation card
session_name: string; // e.g. "Ziua 14 — Mobilizare & detensionare"
session_payload: SessionPayload;
}Phone-side, the response is just { ok: true } — the actual display-token never traverses the phone. This avoids a class of attacks where a compromised phone process inspects or replays the TV's token.
Channel transition (TV)
On receiving the paired event, the TV:
- Closes the anonymous
/v1/session-pairings/{pair_id}/channelSSE. - Opens
GET /v1/session-runs/{run_id}/channel-tv?display_token=<jwt>(the dedicated TV-auth URL — see Channel (both sides, post-pair)). - Renders the confirmation card with
patient_name+session_namefor ~5 seconds. - Starts the conductor (or initializes the iframe
src) on the first exercise.
Channel (both sides, post-pair)
The run channel splits across two URLs per implementation reality — EventSource can't attach custom Authorization headers in any browser, and chi can't dispatch a single path to two different auth middlewares. The split keeps both routes simple:
- Phone:
GET /v1/session-runs/{run_id}/channel-phone?phone_token=<jwt>— phone-token verified inline, mounted at top level (outside the/v1Clerk block: the Portal's cross-originEventSourcecan't carry the Clerk cookie, and the/v1middleware stack would pin a per-request RLS transaction — one pgbouncer server connection — for the stream's whole lifetime). A Clerk-cookie variant at…/channelexisted briefly and was removed for exactly those two reasons; nothing ever consumed it. - TV:
GET /v1/session-runs/{run_id}/channel-tv?display_token=<jwt>— display-token verified inline by the handler, mounted at top level.
Both subscribe to the same hub channel under the hood (run:{run_id} in the Redis pub/sub fan-out), so phone + TV see identical event streams. The asymmetry stays inside the route file; the original "one URL, dual auth" spec was simpler on paper but added a custom dual-auth middleware that wasn't worth the savings.
The server fan-outs every event to all subscribers of the run. There are typically exactly two subscribers; the channel doesn't expose any new attack surface that the per-side auth doesn't already gate.
Auth model
Phone
Existing Clerk auth, no changes. Phone is the only party that can:
- Create the run (
POST /v1/session-runs). - Generate pairing codes.
- POST clinical events (pain, complete) and biometric/telemetry streams (pose, media).
TV (display token)
Display token is a signed JWT with claims:
{
sub: "tv-display",
run_id: string,
org_id: string, // for RLS scope on tv-liveness inserts
exp: number, // session_duration + 1800 (30min grace)
jti: string, // for revocation on /complete
scope: ["channel:subscribe", "tv-liveness:post"]
}Note: no patient_principal_id claim. The server resolves the patient via run_id → run row whenever it needs to. The token sits in a URL/cookie on a device that is by definition less-trusted than the phone; embedding the patient identifier in it would be unnecessary surface for no functional benefit.
The TV display token cannot:
- Read
SessionPayloadoutside thisrun_id's scope. - Read or write any other patient's data.
- POST to clinical / biometric ingest endpoints.
- Outlive the session run.
The scope array is whitelisted — adding a new TV capability requires explicitly extending this set.
TV-fetched data
The TV needs the SessionPayload to render exercises. Rather than the TV pulling it directly (which would require a new authorized read endpoint), the phone pushes it onto the channel after pairing — the paired event carries the full session_payload (already specified in the Pairing protocol). The TV holds it in memory for the session lifetime. This keeps the TV's read surface to exactly: the channel (per-run, display-token-gated) + the tv-liveness POST. No other API access.
Domain contract with the sessions domain
Companion mode is a sub-capability of running a session, not a sibling domain. It depends on the sessions domain (sessions chat — see [[sessions-mvp]]) and lands on top of it.
Tables owned by sessions domain (this doc reads them, does not modify)
session_runs— state, flat. PKidis therun_idcompanion mode references. Carries thestatusenum below.session_pain_events— event-shaped, monthly partitioned. Companion's pain events land here via the existing/v1/session-runs/{run_id}/painendpoint.
Tables owned by companion domain (this doc)
session_pairings— state, swept. Pre-pair holding rows + claim metadata.session_tv_liveness— event-shaped, monthly partitioned per P41. TV liveness heartbeats.
Both FK to session_runs(id) (and organizations(id) for RLS scope). Land in PR #3, after PR #1 (sessions backend) closes.
session_runs.status enum
The sessions chat owns the column; values are seeded in their migration. Companion's auto-close job is the writer for the closed states:
status session_run_status NOT NULL DEFAULT 'in_progress'
-- values (all terminal transitions are server-driven):
-- in_progress -- run created, no terminal trigger yet
-- ended_naturally -- last exercise's terminal event arrived → service flipped
-- ended_explicit -- patient hit End Session → POST /v1/session-runs/{id}/end-early
-- auto_closed -- silence sweep tripped (no exercise event AND no fresh TV heartbeat within the silence window)completed BOOLEAN is the orthogonal signal — TRUE iff every session_exercise received a terminal event (completed OR skipped) before the run terminated. Lets the clinic ask "did the patient finish?" independently of "how did the run end?". See decisions.md → Why session_runs carries both status and completed.
Idempotency
Per Pattern P38, mutating endpoints accept Idempotency-Key: <client-generated-uuid> and store + dedupe server-side. Companion endpoints that follow this:
POST /v1/session-runs/{run_id}/pain(handled by sessions chat; companion just emits the call).POST /v1/session-runs/{run_id}/tv-liveness(own row keyed by(run_id, idempotency_key)to dedupe retried heartbeats).
POST /v1/session-pairings doesn't need idempotency — it's anonymous and one-shot per code; a retry that succeeds simply produces a second valid pairing row, and the old one expires harmlessly. The claim endpoint uses idempotency_key in the request body to dedupe retries (a retried claim with the same key returns 200 instead of 409).
Cross-domain read
Companion's auto-close background job reads session_runs rows to decide what state to write. No direct table queries from the companion domain — per Foundation Discipline, cross-domain reads go through the owning domain's typed repository:
// internal/core/domain/sessions/repository.go (owned by sessions chat)
func (r *Repository) GetRunForAutoClose(ctx context.Context, runID uuid.UUID) (*RunForAutoClose, error)
// internal/core/domain/companion/autoclose/job.go (companion lane)
run, err := sessionsRepo.GetRunForAutoClose(ctx, runID)RunForAutoClose exposes only the fields the auto-close job needs (id, organization_id, patient_id, started_at, completed_at, status, source_kind, source.estimated_duration_s). No PHI beyond what's already in session_runs. Sessions chat exposes this method as part of PR #1.
Permission seeding
Companion's pair / tv-liveness / channel endpoints all gate on the existing session_runs.run permission (seeded by sessions chat). Companion mode is a sub-capability of running a session, not a separate concern — no new permission code. The display token's scope: ["channel:subscribe", "tv-liveness:post"] claim is the in-token capability restriction (TV can do these specific things, nothing else); it does not interact with the per-org permission system.
Endpoint summary (after rename)
| Endpoint | Owner | Auth | Idempotency |
|---|---|---|---|
POST /v1/sessions (+ CRUD) | sessions chat | Clerk + sessions.manage | header |
POST /v1/session-runs | sessions chat | Clerk + session_runs.run | header (also session_runs.idempotency_key column) |
POST /v1/session-runs/{run_id}/pain | sessions chat | Clerk + session_runs.run | header |
POST /v1/session-runs/{run_id}/exercise-event | sessions chat | Clerk + session_runs.run | n/a (append-only) |
POST /v1/session-runs/{run_id}/end-early | sessions chat | Clerk + session_runs.run | idempotent at the row level (status guard) |
POST /v1/session-runs/{run_id}/feedback | sessions chat | Clerk + session_runs.run | idempotent (overwrites) |
POST /v1/session-pairings | companion | Anonymous, rate-limited | n/a (one-shot) |
POST /v1/session-pairings/{pair_id}/claim | companion | Clerk + session_runs.run | n/a (code is the dedup key) |
POST /v1/session-runs/{run_id}/tv-liveness | companion | Display token | header |
GET /v1/session-pairings/{pair_id}/channel | companion | Anonymous, opaque uuid is its own gate | n/a (read-only SSE) |
GET /v1/session-runs/{run_id}/channel-phone | companion | Phone token (via ?phone_token= query) | n/a (read-only SSE) |
GET /v1/session-runs/{run_id}/channel-tv | companion | Display token (TV, via ?display_token= query) | n/a (read-only SSE) |
Resolved with sessions chat (2026-05-18): pairings are a top-level resource under /v1/session-pairings/*, not nested under /v1/session-runs/*. The run doesn't exist until claim — pre-claim there's nothing to nest under. The phone's claim endpoint creates the session_runs row as part of its handler, atomically with the pairing link-up.
TV is a separate app, not a Portal route
Architectural decision (2026-05-18): the TV surface lives in
apps/tv/, served atrestartix.tv, built as standalone static HTML + vanilla JS + plain CSS. It is not a route group insideapps/portal/. See [[project_device_classes_split]] for the broader two-stack rationale.
Why
The Portal app gates browsers below Tailwind v4's baseline (Chromium 111+ / Safari 16.4+ / Firefox 113+) via apps/portal/proxy.ts → isUnsupportedBrowser(ua) — anything older is rewritten to a static /unsupported page. This gate exists because Tailwind v4's oklch() color values don't parse on older engines and React 19 / Next 16 features don't execute on Chromium 100-. The gate is the right call for the patient-facing app (small minority of devices, large maintenance cost to keep working).
But the TV browser space is genuinely legacy: Tizen 4 ships Chromium 56, webOS pre-22 ships variants of Chromium 76-94, and even some "current" smart-TV browsers lag the desktop curve by years. A (tv) route group inside Portal would be subject to the same proxy gate — every legacy TV would land on /unsupported. The spike confirmed Tizen 4 / Chromium 56 doesn't execute the Next/React/Tailwind bundle even before the gate runs.
Hand-written vanilla JS targeting ES2017 + RGB hex colors (Chrome 55+ / Safari 11+ baseline) bypasses every one of these constraints. The TV surface is small enough (~200–300 LOC end-to-end) that React buys nothing — no client-side state worth memoizing, no large component tree, no need for the reconciler to run on weak TV CPUs. One codebase serves all TVs uniformly, modern and legacy.
What the TV app contains
apps/tv/
├── index.html # pairing entry — code input + branding (Phase 1.5: per-clinic)
├── play.html # iframe player + SSE channel client (post-claim)
├── style.css # ~80 lines plain CSS, RGB hex only, no oklch / no var() chains
├── pairing.js # POST /v1/session-pairings on load → SSE await `paired` event → redirect to play.html with display_token
├── channel.js # EventSource client + command handler + tv-liveness poster
└── player.js # iframe src in_place swap + load tracking- No build step required. Plain files served from S3 + CloudFront (or Cloudflare Pages). Independent deploy from Portal.
- No npm dependencies. Vanilla
fetch+EventSource. Both available in Chromium 56+. - No framework lock-in. When a TV vendor introduces yet another quirk, the fix is in plain JS — no React-version constraint to worry about.
Hosting + deploy
restartix.tv (generic, MVP) → S3 bucket + CloudFront distribution (or Cloudflare Pages, decision deferred to whoever lands the infra PR). Same apps/tv/ bundle serves all subdomains.
Phase 1.5 per-clinic subdomain ({slug}.restartix.tv) is a CloudFront wildcard cert + a small bootstrap JS hop that reads window.location.hostname, calls GET /v1/public/organizations/resolve?slug={slug}, and applies clinic logo + colors to the entry page. Same code, same bucket; no per-clinic build.
Portal → TV soft redirect
A modern Tizen 6 TV that types portal.restartix.pro instead of restartix.tv is above the Portal proxy's Chromium-111 gate, so it slips through and lands in the regular Portal flow — missing companion mode entirely. To keep "one path for TVs" intact, the Portal proxy gains a smart-TV UA detection ahead of the unsupported-browser gate:
// apps/portal/proxy.ts (sketch)
if (isSmartTV(ua)) return Response.redirect("https://restartix.tv/", 302);
if (isUnsupportedBrowser(ua)) return rewrite(...);The same UA regex SessionStage currently uses (/web0S|webOS|SmartTV|SMART-TV|Tizen|HbbTV|NetCast/i) is the source of truth. This means the existing SmartTVStage branch in apps/portal/components/session/session-stage.tsx becomes dead code once companion mode ships — modern TVs are bounced at the proxy before they reach the kiosk path, legacy TVs were never reaching it. Soft-deprecate then delete as part of the companion ship.
CORS
API allowed origins must include https://restartix.tv (and the wildcard *.restartix.tv once Phase 1.5 lands). Endpoints affected:
POST /v1/session-pairingsGET /v1/session-pairings/{pair_id}/channelGET /v1/session-runs/{run_id}/channel-tvPOST /v1/session-runs/{run_id}/tv-liveness
One config change in the API's CORS middleware; lands with the apps/tv/ deploy.
What this displaces from the original design
| Original design | Revised |
|---|---|
(tv) route group inside apps/portal/app/(tv)/play/[runId]/ | Standalone apps/tv/ app at restartix.tv. |
| Conductor + cue overlays on TV (modern path) | Iframe player only on all TVs. Cue overlays don't run on TV — input is on the phone. |
SessionStage smart-TV branch as preserved rendering | Deleted at companion ship. Portal proxy bounces smart TVs to restartix.tv first. |
| Inline-styled Portal chrome on legacy TVs | Plain CSS hand-written; no Tailwind workarounds. |
Display token cookie scoped to (tv) path | Cookie scoped to restartix.tv domain. |
Channel protocol
Channel transport: Server-Sent Events from server → each subscriber + REST POSTs from subscribers → server (server fan-outs as SSE).
Messages: phone → TV
| Type | Payload | When |
|---|---|---|
session_payload | SessionPayload | Once, right after pairing. |
command_pause | {} | Patient taps "Pauză" on phone. |
command_resume | {} | Patient taps "Continuă". |
command_skip_exercise | {} | Patient taps "Sari" on phone. |
command_skip_rest | {} | Patient taps "Sari peste pauză". |
command_end_session | {} | Patient taps "Termină sesiunea". |
command_seek | { to_exercise_idx, to_time_s? } | Reserved; not in MVP. |
Messages: TV → phone
| Type | Payload | When |
|---|---|---|
tv_state | SessionPlayerSnapshot (see index.md) | Throttled to ~4 Hz during playing; immediately on status transitions. |
tv_error | { reason: ErrorReason; detail?: string } | Conductor enters error state. |
tv_done | {} | Conductor reaches done state (final exercise complete). |
Phone-side use of tv_state
The phone uses tv_state.currentExerciseIdx + currentCue + exerciseVideoTime to render the live progress strip on the companion screen, and to enrich pain events with full clinical context:
// When the patient taps "Mă doare" on phone:
const tvState = lastReceivedTvState;
POST /v1/session-runs/{run_id}/pain {
exercise_id: tvState.currentExerciseId,
set_idx: tvState.currentCue?.set ?? 0,
side: tvState.currentCue?.side ?? null, // "left" | "right" | null
seconds_into_set: secondsIntoSet(tvState),
severity, action, // from the phone's PainSheet
}The phone never makes up exercise context — it always derives from the most-recent tv_state. This keeps the clinical record accurate even though pain was reported from a device that isn't running the player.
TV liveness — the heartbeat continuity story
The phone is the only authorized source for clinical / telemetry / biometric ingest. If the phone disconnects mid-session, those streams stop. Without a counter-signal, the server would have no idea whether the patient was still working out or had bailed.
Resolution: the TV emits a separate, narrow liveness signal that proves the session was running.
POST /v1/session-runs/{run_id}/tv-liveness (display-token auth, 10s cadence):
{
ts: string; // ISO 8601
conductor_status: SessionPlayerStatus;
current_exercise_idx: number | null;
exercise_video_time_s: number;
network_state: "healthy" | "degraded"; // from the TV's own connection monitor
}Server-side: rows append to session_tv_liveness (event-shaped table, monthly partitioned per P41). The endpoint is not the same surface as /v1/media/events — different consent surface, different table, no PHI beyond what's already in session_runs.
Consent classification: service_operations (same bucket as audit log + system health). Not analytics, not biometric, not clinical_record. The rows carry execution metadata (conductor_status, current_exercise_idx, network_state) — operationally necessary to determine whether a session was running, with no upstream marketing or research consumer. As with audit log, no opt-out: it's how the platform proves the session existed and concluded. Data-classification registry entry lands with the migration.
Phone-side offline buffering
Pain events, media heartbeats, and pose batches queue in IndexedDB when the phone is offline. On reconnect, the phone flushes them in order. Idempotency keys on each event prevent double-write on retry.
Auto-close on silence
A run is auto-closed when it has gone silent — no proof of life for longer than the per-signal window — not when it has merely run long. There are two independent proof-of-life signals, OR'd together: the TV liveness heartbeat (companion runs, every 10s) and session_exercise_events (the phone-conductor signal, present for phone-only runs too). The cron's candidate query surfaces in-progress runs where COALESCE(last_exercise_event, started_at) is older than the activity window AND the TV is not freshly heartbeating; the per-run decision (autoCloseDecision) then re-reads both signals and chooses:
- Recent exercise event (within
activitySilenceWindow, 30min) → SKIP, loggedactive. The phone is still conducting — for a phone-only run this is the only signal, and it also backstops a companion run whose TV heartbeat dropped while the phone kept driving. Closing here would kill a session mid-exercise. - Latest liveness is
conductor_status === "done", younger than 2h → SKIP. The run staysin_progressso the deferred-feedback flow can land feedback when the phone returns. Past 2h (done-but-gone: the phone never came back) the run is closed via the normal path below — its event-derivedcompletedstill credits a full play-through. - Latest liveness is any other status, younger than the staleness window (5min; TV heartbeats every 10s) → SKIP, logged
live. The TV is alive — playing or paused — and closing would revoke its display token and 401-kick a live session. The skip is bounded by the display-token TTL (×2 + grace, ≤4h): when the token expires the TV exits, heartbeats stop, and the next sweep closes the run. - Heartbeat stale (TV gone) AND events silent → call
Service.AutoCloseRun(runID). Status becomesauto_closed; the service derivescompletedandexercises_completedfromsession_exercise_eventsinside the same write. Cron's metric label:partial. - No TV liveness ever AND events silent (phone-only run the patient walked away from, or TV never paired) → same
Service.AutoCloseRun(runID)call. Cron's metric label:unknown.
Why not
estimated_duration_s + 900? The original threshold was a total wall-clock cap measured fromstarted_at, blind to whether the patient was still exercising. Because pause time counted against it, a patient who took a legitimate break (or simply went slower than the estimate) was auto-closed mid-session. In production this madesilence_timeoutthe single largest terminal outcome, closing runs where the last exercise event landed seconds before the kill. Auto-close now keys off silence, so a run survives as long as either signal shows life.
The cron does not pass a status to the service — it always writes auto_closed. The partial/unknown distinction lives in the cron's observability counters (label), not in the row's status enum. On the row, a clinic dashboard derives "partial vs unknown" from exercises_completed > 0.
Net effect: phone losing battery degrades input, not clinical record. The specialist sees a session that was running, with completed and exercises_completed reflecting what actually happened.
Failure modes
| Scenario | Behavior |
|---|---|
| Phone disconnects mid-session | TV keeps playing. Pain reporting, skip, end-session are unavailable on phone until reconnect. TV liveness keeps flowing. Phone events flush from IndexedDB on reconnect. |
| Phone never reconnects | Auto-close per the rules above. |
| TV disconnects / closes | Phone shows toast: "S-a pierdut conexiunea cu TV." Two CTAs: Reconectează (TV re-opens, generates fresh code, phone re-claims; run continues) / Continuă pe telefon (fall back to phone-side playback). See "Seek-on-fallback" below — the phone-side conductor mounts at the position the TV was at, not zero. Run row is owned by phone; no data is lost. |
| Both offline simultaneously | TV stops emitting liveness; phone stops emitting clinical events. Auto-close kicks in based on whatever was last observed. |
| Pairing code expires before phone claim | TV's anonymous SSE receives an expired event; TV auto-calls pair/create for a fresh code. No user action needed; new code renders in-place. |
| Wrong session paired (shared household) | With reverse-pairing the risk is essentially absent: the patient is looking at the TV they want to use when they type the code from it into the phone. The wrong TV would need to be the one the patient is reading the code from. As belt-and-braces, the TV's "ready to start" confirmation card displays patient_name + session_name; if anything looks off, patient hits "Anulează" on phone, server invalidates the pair (sets pair_session to expired, revokes display_token), TV's run channel closes and it auto-refreshes to a fresh pair_session. |
| Patient picks "Pe TV" but TV is unreachable | Patient falls back to "Pe telefon" with no penalty — phone enters the existing kiosk flow. |
Seek-on-fallback
When the patient picks "Continuă pe telefon" after a TV disconnect, the phone needs to resume the run at the exercise and the position the TV was at — not start over from exercise 0. A single "seek-to-seconds" value isn't enough: a drop on exercise 5 at 0:42 has to land on exercise 5, not on exercise 0. The right primitive carries both:
<SessionPlayerProvider
session={...}
resumeFrom={{ exerciseIdx: number; videoTimeS: number }}
>Mechanic:
- Phone keeps the most-recent
tv_statein memory throughout the companion session (already needed for pain-event enrichment). - On "Continuă pe telefon" tap, phone navigates to
/sessions/{run_id}/run/exercise/{idx}where{idx}istv_state.currentExerciseIdx, and mountsSessionPlayerProviderwithresumeFrom: { exerciseIdx: tv_state.currentExerciseIdx, videoTimeS: tv_state.exerciseVideoTime }. - Conductor stashes
resumeFrom; on prime, jumps to the target exercise and marks earlier ones"done"so the snapshot stays consistent (UI shows correct progress through the dot strip). - When
loadeddatafires on the target exercise's<video>, conductor setsvideoEl.currentTime = videoTimeSbeforeplay(). Safari gesture inheritance still applies because the user just tapped "Continuă pe telefon."
If tv_state is stale (last seen >30s ago — TV likely crashed before sending its last snapshot), phone passes videoTimeS: 0 rather than the stale time. Acceptable — at worst the patient repeats half an exercise.
Implementation note: SessionConductor today plays from currentTime = 0 on attach (per the just-landed playback engine), and walks exercises linearly from index 0. Adding resumeFrom is ~30 LOC in the conductor + a tiny types extension — same primitive a future "resume yesterday's incomplete session" surface would want, so worth landing as the broader shape, not a narrower per-exercise seek-only variant. Lives in the player-owning lane; bundles into the next conductor edit pass.
Routes
Phone-side routes (Portal — modern stack)
apps/portal/app/
├── (patient)/sessions/page.tsx picker — assignments + active runs
└── (kiosk)/
├── sessions/[sessionId]/start/ template scope
│ ├── safety/page.tsx phone prep
│ ├── materials/page.tsx phone prep
│ ├── pose-opt-in/page.tsx phone prep
│ ├── where-to-play/page.tsx device picker (Pe telefon / Pe TV)
│ └── pair-tv/page.tsx phone: manual code entry (QR scan future)
└── runs/[runId]/ run scope (run already created)
├── layout.tsx fetches /payload + mounts conductor
├── exercise/[step]/page.tsx existing phone-only path
│ (+ resumeFrom prop on provider for fallback)
├── companion/page.tsx phone: companion UI (camera mirror OR progress strip)
├── feedback/page.tsx phone
└── done/page.tsx phoneParam semantics: [sessionId] under /start/* is unambiguously the session template id; [runId] under /runs/* is unambiguously session_runs.id. No polymorphism across sub-routes (see [[url-param-one-meaning]] in memory).
The phone-side stays in Portal (modern Next 16 / React 19 / Tailwind v4). The pairing UI is a code scanner (preferred) with a manual entry fallback — the TV displays both QR and 6-digit code.
TV-side app (standalone — apps/tv/)
apps/tv/
├── index.html # pairing entry: TV calls /v1/session-pairings on load, displays code + QR
├── play.html # iframe player + SSE channel + tv-liveness poster
├── style.css
├── pairing.js
├── channel.js
└── player.jsHosted at restartix.tv. Anonymous (no Clerk gate). Display token from paired event → Secure; HttpOnly; SameSite=Strict cookie scoped to the domain. Phase 1.5: per-clinic subdomain at {slug}.restartix.tv. See TV is a separate app, not a Portal route for the architecture rationale.
Removed from the route surface
- No
(tv)route group inside Portal. Previous iteration of this doc placed TV inside Portal; superseded by the standalone app decision above. SessionStage's smart-TV branch deleted at companion ship. Portal proxy bounces smart-TV UAs torestartix.tvahead of the unsupported-browser gate, so the SmartTVStage branch is unreachable post-companion. Soft-delete with companion PR; hard-delete one release later.
Companion UI shapes
Pose tracking OFF
Phone shows:
┌─────────────────────────────────┐
│ Sesiunea ta rulează pe TV │
├─────────────────────────────────┤
│ Exercițiul 3 din 8 │
│ Mobilizare gât │
│ Set 1 din 2 · stânga │
│ [────────── 0:42 / 1:30 ──] │
├─────────────────────────────────┤
│ │
│ [ Mă doare ] │
│ [ Sari exercițiul ] │
│ [ Pauză ] │
│ │
├─────────────────────────────────┤
│ [ Termină sesiunea ] │
└─────────────────────────────────┘Always-on. Progress strip is driven by tv_state snapshots.
Caregiver-operated sessions
Many telerehab patients (post-op, elderly, cognitively-impaired) have a caregiver operate the phone for them. The companion architecture works for caregiver-operated sessions without any companion-specific change — the phone is still Clerk-authenticated, the channel still flows, the run row is still created normally. Whether the human tapping is the patient or a caregiver is invisible at this layer.
One physical constraint to be aware of: pose tracking + TV mode is incompatible with caregiver operation. Pose-on assumes the phone's camera is pointed at the patient — natural when the patient is the one mounting the phone next to their mat. Caregiver-operated sessions have the patient at the TV / mat and the phone several meters away in the caregiver's hand; the camera would be pointed at the caregiver, not the patient. The pose-opt-in step already lets the patient (or caregiver acting for them) decline tracking with "Continuă fără urmărire," so no enforcement UI is needed — caregivers naturally pick the no-tracking path.
The deeper "did patient rate this pain or did the caregiver guess?" question (act-on-behalf event attribution) is a platform-wide concern handled at the principal/identity layer, not here. Companion mode passes through whatever the auth context says without trying to disambiguate.
Pose tracking ON
Phone shows full-bleed camera mirror with control chips overlaid at the bottom:
┌─────────────────────────────────┐
│ │
│ │
│ [ patient mirror ] │
│ │
│ │
│ │
│ │
├─────────────────────────────────┤
│ Set 1/2 · stânga · 0:42/1:30 │
│ [Mă doare] [Sari] [Termină] │
└─────────────────────────────────┘Controls are smaller (less screen real-estate), camera mirror dominates. Tapping Mă doare opens the same PainSheet as the phone-only path.
When a structured-feedback prompt fires mid-session (e.g. post-block RPE), the TV pauses; the phone shrinks the camera to a corner and surfaces the input sheet full-screen. Patient taps the answer; sheet dismisses; camera restores; TV resumes.
What this supersedes
The existing Smart-TV fallback section in index.md describes a v0 standalone TV surface: degraded player, no overlays, no auto-advance, one exercise per view. That model is replaced entirely by companion mode, and once companion ships the SmartTVStage branch in session-stage.tsx is dead code.
What changes:
- TV is no longer a degraded standalone surface. Overlays are not needed on TV because input has moved to the phone.
- TV is no longer part of Portal. It's its own standalone app at
apps/tv/(plain HTML/CSS/JS, no React). See TV is a separate app, not a Portal route. - Auto-advance works on all TVs. The phone holds the
session_payload(delivered in thepairedevent) and tells the TV which video to load next via the channel. The TV'splayer.jsjust swaps the iframesrcin_place when commanded; no conductor needed on the TV side. - No inline-styled Portal chrome on smart TVs. The TV app uses plain hand-written CSS with RGB hex values targeting ES2017+ engines. The original "inline styles to dodge oklch" workaround is irrelevant —
apps/tv/never loads Tailwind. SessionStagesmart-TV branch is deleted. Portal proxy bounces smart-TV UAs torestartix.tvbefore they reach the kiosk path; the branch is unreachable post-ship.
The index.md Smart-TV fallback section will get a "superseded by companion mode" note pointing here, in a follow-up doc edit (already partly done — header banner exists).
Phasing
Phase 0 — completed 2026-05-18
Spike verified iframe src swap on Samsung Tizen 6.0 / Chromium 120:
in_placeswap (iframe.src = newUrl): works, ~2,252 ms to iframe load event.remount(React-style full unmount + remount): works, identical ~2,252 ms.
Picked in_place — simpler, no remount flash, and the ~2.3s window is fully masked by the inter-exercise rest countdown (10s minimum in current mock-data). Plan B (unmount/remount with loading flash) is not needed.
Tizen 4.0 / Chromium 56 is unreachable for the spike via the Portal stack — the React 19 / Next 16 / Tailwind v4 bundle doesn't execute on Chromium 56. This was the trigger for the TV-as-separate-app decision: the TV surface lives outside Portal entirely, so legacy TVs never need to load the Portal bundle.
Phase 1 PR split
Companion mode depends on the sessions backend domain (sessions chat). Three PRs land in order:
PR #1 — Sessions backend (sessions chat, blocks PR #3)
sessions+session_exercises+session_runs+session_pain_eventsmigrations.session_run_statusenum (in_progress | ended_naturally | ended_explicit | auto_closed);completedboolean. Server-driven terminal transitions per decisions.md → Why session_runs carries both status and completed.POST /v1/sessions(clinic CRUD) +POST /v1/session-runs+POST /v1/session-runs/{run_id}/pain+POST /v1/session-runs/{run_id}/exercise-event(triggers natural-completion server-side) +POST /v1/session-runs/{run_id}/end-early+POST /v1/session-runs/{run_id}/feedback.- Permission seeding (
sessions.read,sessions.manage,session_runs.run). repo.GetRunForAutoClose(ctx, run_id)exposed for companion's auto-close job.
PR #2 — Companion parallel work (companion chat, no backend dep)
These ship in parallel with PR #1 since they don't depend on the sessions backend existing:
apps/tv/standalone app — HTML/CSS/JS atrestartix.tv. Plain HTML/CSS/JS, no React, no Tailwind, no build step. Targets ES2017 / Chromium 55+. Uses mocked endpoints during PR #1's build window.- Pairing entry page (TV-side
pair/createcall on load, displays code + QR). - SSE EventSource client on the pair channel awaiting
paired. - Post-pair: iframe player with
in_placesrcswap on each exercise transition. - SSE channel client (run channel) consuming phone commands.
- Display token cookie management (
Secure; HttpOnly; SameSite=Strict, domain-scoped). - Portal proxy → TV smart-TV UA detection + 302 redirect to
https://restartix.tv/(ahead of the existingisUnsupportedBrowsergate). - Display token JWT minting + verification helpers (Go).
- Hosting decision + infra (S3 + CloudFront or Cloudflare Pages).
PR #3 — Companion backend on top of sessions (companion chat, after PR #1)
session_pairings(state, swept) +session_tv_liveness(event, monthly partitioned) migrations + data-classification registry entries.POST /v1/session-pairings(anonymous, rate-limited per IP).POST /v1/session-pairings/{pair_id}/claim(Clerk auth, usessession_runs.runpermission).POST /v1/session-runs/{run_id}/tv-liveness(display-token auth).GET /v1/session-pairings/{pair_id}/channel(SSE, anonymous).GET /v1/session-runs/{run_id}/channel-phone(SSE, phone-token via?phone_token=; an earlier Clerk-cookie…/channelvariant was removed — cross-origin EventSource can't carry the cookie, and the/v1stack pinned an RLS transaction per stream).GET /v1/session-runs/{run_id}/channel-tv(SSE, display-token via?display_token=).POST /v1/session-runs/{run_id}/commands(Clerk, phone→TV).POST /v1/session-runs/{run_id}/tv-state(display-token, TV→phone).- CORS allowed origins:
https://restartix.tv(+https://*.restartix.tvfor Phase 1.5). - Pairing-code logging redaction (structured-logger rule applied repo-wide for
codefields onpair/*endpoints). - Phone-side Portal routes:
(kiosk)/sessions/[id]/run/pair-tv/page.tsx(camera-based QR scanner with manual entry fallback) +(kiosk)/sessions/[id]/run/companion/page.tsx(companion UI both shapes). - IndexedDB buffer for offline event queue + idempotency keys on existing ingest endpoints.
- Auto-close background job (consumer of
session_tv_liveness, callssessionsRepo.GetRunForAutoClose, callsService.AutoCloseRunwhich writesstatus='auto_closed'and server-derivescompleted+exercises_completed). SessionStagesmart-TV branch soft-deleted (kept compiled, never reached); hard-delete one release later.
resumeFrom prop on SessionPlayerProvider — lives in the core-player lane (sessions chat or session-player chat), bundles opportunistically into the next conductor edit pass. Not a blocker for PR #2 or PR #3.
Phase 1.5 (post-MVP, no specific gate)
- Per-clinic subdomain (
{slug}.restartix.tv) with clinic logo on entry screen — picks up from the white-label tokens already inorganization_branding. - Patient name confirmation on TV's "ready to start" screen (anti-misclaim UX).
- Mid-session structured-feedback gates (RPE between blocks, etc.) — the channel protocol supports it from day one; the actual prompts are out of scope for Phase 1.
Out of scope (not on the roadmap)
- Persistent TV pairing ("remember this TV"). Per-session code is cheap; persistent device tokens add a security model (revocation, shared-household risk, token rotation) without clear demand. Revisit if patients ask for it.
- Real-time casting (phone screen → TV via WebRTC). Heaviest to build; companion mode delivers the use case (phone-driven session on big screen) without it.
- Phone-as-remote during exercise playback (scrub bar, pause/play overlay). The channel supports it; we just don't expose those controls. Add only if usage justifies.
- Multiple TVs per session. First TV to claim wins; second TV gets 409. No multi-screen scenario is in the product brief.
Token model — quick reference
| Token | Issued to | Auth context | Lifetime | Scope |
|---|---|---|---|---|
| Pairing code | TV (anonymous pair/create) | Bearer-on-claim (phone must hold Clerk auth to claim) | 1 min, one-shot | Bind one pre-paired TV to one run_id at claim time. Never logged. |
pair_id | TV (anonymous) | None — opaque uuid is its own gate | 1 min (matches code TTL) | Subscribe to anonymous pair channel awaiting paired event. |
| TV display token | TV (issued via paired event after claim) | Bearer (URL → HttpOnly cookie on first land) | session.estimated_duration_s + 1800 | Channel subscribe + tv-liveness POST. Scoped to one run_id. No patient_principal_id claim. |
| Clerk session | Phone | Existing | Existing | Everything else. Unchanged. |
Open items for implementation chat
The big two (reverse pairing direction, slim TV token claims) are resolved in the doc body above. Remaining items are sharpening, not redesign:
- Cross-clinic patient context on TV. A patient with two clinic subscriptions might pair from one clinic but enter the generic
restartix.tvURL. The pairing claim resolves to arun_idwhich already carriesorg_id— TV inherits that scope via the display token'sorg_idclaim. Confirm no clinic-switch UI is needed on TV. - TV reconnect after browser refresh. If patient hits "Refresh" on TV browser mid-session, the display token from URL would be gone. Implementation: server sets a
Secure; HttpOnly; SameSite=Strictcookie on first TV land scoped to the(tv)path, expiring with the token. Refresh re-attaches to the run channel without re-pairing. The URL-token form is for the first land only; thereafter the cookie is authoritative. session_tv_livenessretention. Operational signal, not clinical record. Proposal: 90 days hot in PostgreSQL, then drop. Confirm with the data-retention spec forservice_operations-class data.session_pairingssweep cadence. Short-lived rows (1 min TTL); a cron every 15 min to deleteexpires_at < now() - 1his sufficient. Slot into existing scheduled-jobs infrastructure.
Related
- Patient Session Player — the player surface that companion mode wraps.
- Exercise library — cue manifest — the metadata sidecar both sides consume.
- Telemetry — media events — engagement event spec; phone-side, unchanged.
- P41 — Event table range partitioning — pattern that
session_tv_livenessfollows. - Anonymous kiosk gates revert — the now-removed temporary anon access; companion mode is the proper successor.