Skip to content

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 (carries conductor:"phone" + session_name resolved to the patient's locale + locale itself — the TV is org-less, so the phone resolves locale on its behalf and the TV's i18n.js switches its chrome strings + exercise-name envelope resolution; server-retained + replayed on channel-tv connect, and the phone retries publish until the first tv_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_rest survive 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_s countdown → … → 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-emitter hook) — ended_naturally flips server-side on the last terminal milestone. tv_done/tv_error events 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 sends release_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

ConcernPhoneTV
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/done

The 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)

ts
// 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/claim returns 409.
  • Pre-run — code is bound to a pair_id, not a run_id. The run_id is 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/create again 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 code field on a pair/* 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 paired event (claim succeeded — see below).
  • A expired event (TTL elapsed; TV auto-refreshes).

Claim (phone)

POST /v1/session-pairings/{pair_id}/claim (Clerk auth — phone is authenticated)

ts
// 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 org

The 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:

  1. Validate code against session_pairings row — not expired, not already claimed (or, if claimed with same idempotency_key, return 200 idempotently).
  2. Validate caller's access to source_kind + source_id at the current org (RLS + permission check via session_runs.run).
  3. Insert session_runs row with status = 'in_progress', returning run_id.
  4. Update session_pairings row: run_id = X, claimed_at = now(), claimed_by_principal_id = current.
  5. Mint display_token JWT (claims: run_id, org_id, scope, exp).
  6. Emit paired event on /v1/session-pairings/{pair_id}/channel carrying:
ts
{
  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:

  1. Closes the anonymous /v1/session-pairings/{pair_id}/channel SSE.
  2. Opens GET /v1/session-runs/{run_id}/channel-tv?display_token=<jwt> (the dedicated TV-auth URL — see Channel (both sides, post-pair)).
  3. Renders the confirmation card with patient_name + session_name for ~5 seconds.
  4. 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 /v1 Clerk block: the Portal's cross-origin EventSource can't carry the Clerk cookie, and the /v1 middleware stack would pin a per-request RLS transaction — one pgbouncer server connection — for the stream's whole lifetime). A Clerk-cookie variant at …/channel existed 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:

ts
{
  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 SessionPayload outside this run_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. PK id is the run_id companion mode references. Carries the status enum below.
  • session_pain_events — event-shaped, monthly partitioned. Companion's pain events land here via the existing /v1/session-runs/{run_id}/pain endpoint.

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:

sql
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:

go
// 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)

EndpointOwnerAuthIdempotency
POST /v1/sessions (+ CRUD)sessions chatClerk + sessions.manageheader
POST /v1/session-runssessions chatClerk + session_runs.runheader (also session_runs.idempotency_key column)
POST /v1/session-runs/{run_id}/painsessions chatClerk + session_runs.runheader
POST /v1/session-runs/{run_id}/exercise-eventsessions chatClerk + session_runs.runn/a (append-only)
POST /v1/session-runs/{run_id}/end-earlysessions chatClerk + session_runs.runidempotent at the row level (status guard)
POST /v1/session-runs/{run_id}/feedbacksessions chatClerk + session_runs.runidempotent (overwrites)
POST /v1/session-pairingscompanionAnonymous, rate-limitedn/a (one-shot)
POST /v1/session-pairings/{pair_id}/claimcompanionClerk + session_runs.runn/a (code is the dedup key)
POST /v1/session-runs/{run_id}/tv-livenesscompanionDisplay tokenheader
GET /v1/session-pairings/{pair_id}/channelcompanionAnonymous, opaque uuid is its own gaten/a (read-only SSE)
GET /v1/session-runs/{run_id}/channel-phonecompanionPhone token (via ?phone_token= query)n/a (read-only SSE)
GET /v1/session-runs/{run_id}/channel-tvcompanionDisplay 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 at restartix.tv, built as standalone static HTML + vanilla JS + plain CSS. It is not a route group inside apps/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:

ts
// 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-pairings
  • GET /v1/session-pairings/{pair_id}/channel
  • GET /v1/session-runs/{run_id}/channel-tv
  • POST /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 designRevised
(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 renderingDeleted at companion ship. Portal proxy bounces smart TVs to restartix.tv first.
Inline-styled Portal chrome on legacy TVsPlain CSS hand-written; no Tailwind workarounds.
Display token cookie scoped to (tv) pathCookie 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

TypePayloadWhen
session_payloadSessionPayloadOnce, 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

TypePayloadWhen
tv_stateSessionPlayerSnapshot (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:

ts
// 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
{
  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, logged active. 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 stays in_progress so 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-derived completed still 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 becomes auto_closed; the service derives completed and exercises_completed from session_exercise_events inside 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 from started_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 made silence_timeout the 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

ScenarioBehavior
Phone disconnects mid-sessionTV 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 reconnectsAuto-close per the rules above.
TV disconnects / closesPhone 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 simultaneouslyTV stops emitting liveness; phone stops emitting clinical events. Auto-close kicks in based on whatever was last observed.
Pairing code expires before phone claimTV'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 unreachablePatient 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:

ts
<SessionPlayerProvider
  session={...}
  resumeFrom={{ exerciseIdx: number; videoTimeS: number }}
>

Mechanic:

  1. Phone keeps the most-recent tv_state in memory throughout the companion session (already needed for pain-event enrichment).
  2. On "Continuă pe telefon" tap, phone navigates to /sessions/{run_id}/run/exercise/{idx} where {idx} is tv_state.currentExerciseIdx, and mounts SessionPlayerProvider with resumeFrom: { exerciseIdx: tv_state.currentExerciseIdx, videoTimeS: tv_state.exerciseVideoTime }.
  3. 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).
  4. When loadeddata fires on the target exercise's <video>, conductor sets videoEl.currentTime = videoTimeS before play(). 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                                       phone

Param 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.js

Hosted 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 to restartix.tv ahead 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 the paired event) and tells the TV which video to load next via the channel. The TV's player.js just swaps the iframe src in_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.
  • SessionStage smart-TV branch is deleted. Portal proxy bounces smart-TV UAs to restartix.tv before 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_place swap (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_events migrations.
  • session_run_status enum (in_progress | ended_naturally | ended_explicit | auto_closed); completed boolean. 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 at restartix.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/create call on load, displays code + QR).
  • SSE EventSource client on the pair channel awaiting paired.
  • Post-pair: iframe player with in_place src swap 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 existing isUnsupportedBrowser gate).
  • 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, uses session_runs.run permission).
  • 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 …/channel variant was removed — cross-origin EventSource can't carry the cookie, and the /v1 stack 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.tv for Phase 1.5).
  • Pairing-code logging redaction (structured-logger rule applied repo-wide for code fields on pair/* 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, calls sessionsRepo.GetRunForAutoClose, calls Service.AutoCloseRun which writes status='auto_closed' and server-derives completed + exercises_completed).
  • SessionStage smart-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 in organization_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

TokenIssued toAuth contextLifetimeScope
Pairing codeTV (anonymous pair/create)Bearer-on-claim (phone must hold Clerk auth to claim)1 min, one-shotBind one pre-paired TV to one run_id at claim time. Never logged.
pair_idTV (anonymous)None — opaque uuid is its own gate1 min (matches code TTL)Subscribe to anonymous pair channel awaiting paired event.
TV display tokenTV (issued via paired event after claim)Bearer (URL → HttpOnly cookie on first land)session.estimated_duration_s + 1800Channel subscribe + tv-liveness POST. Scoped to one run_id. No patient_principal_id claim.
Clerk sessionPhoneExistingExistingEverything 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.tv URL. The pairing claim resolves to a run_id which already carries org_id — TV inherits that scope via the display token's org_id claim. 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=Strict cookie 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_liveness retention. Operational signal, not clinical record. Proposal: 90 days hot in PostgreSQL, then drop. Confirm with the data-retention spec for service_operations-class data.
  • session_pairings sweep cadence. Short-lived rows (1 min TTL); a cron every 15 min to delete expires_at < now() - 1h is sufficient. Slot into existing scheduled-jobs infrastructure.