Companion Surface Design, v0 — the caller's second screen
Status: design handoff for the implementing Claude — open decisions marked OD-CSn
Date: 2026-07-02
Responds to: 2026-07-02-companion-surface-fable5-brief.md; supersedes the bespoke Space Channel page as the caller-facing target
Depends on: companion-channel-design (link/token/receipt), remote-control-design (InputEvent, arbiter, surface schema, capability tiers), scheduler-design (offers as quotes), quality-contract (RunRecords), flow-blueprint-shift-left (skeleton-provided correction routes)
Two invariants carried throughout: the call is always voice-complete (I7), and every web action is just another deterministic input to the same router (I8/R6).
1. Product principle and use cases
Principle: the page is a live sign board plus a shared memory. It renders exactly what the compiled artifact says the current station offers (never inventing controls — R4), and it externalizes what RIFF believes so the caller can catch errors before they become bookings. Its core loop is bidirectional reflection:
- Say it, see it. A spoken answer appears as a fact chip within ~1s of the
collect_turnevent. - Tap it, hear it. A tap routes through the shared arbiter; the FSM's next spoken state is the acknowledgment. The screen and the ear always agree on where the call is.
Use cases A–F from the brief are adopted verbatim as the scope: menu mirror, offers-as-buttons, visible facts, form assist, progress/outcome, receipt.
2. Current-state assessment
The foundation is real and tested: InputEvent normalization + InputArbiter (first-valid-wins, stale/locked/invalid drops), the flag-gated companion_control WS path with dedupe and per-call scoping, riffc surface emission with L0-10/11/12, public scoped endpoints, and endpoint/control test suites. The gaps are exactly the brief's list: _companion_spec_provider returns None (control frames drop cleanly but never route), the page is Space Channel-bespoke with a hardcoded label map, no generic surface renderer, no tap send path, no forms, no trust panel, no offer rendering. Assessment: this is a wiring-and-renderer project, not an architecture project — the design below adds no new engine concepts, only the caller-facing contract and its rendering rules.
3. Caller page information architecture
Three fixed bands, one mobile column, one-handed (R13):
┌──────────────────────────────┐
│ ① IDENTITY Bella's · ● live │ tenant name/logo · status dot
├──────────────────────────────┤
│ ② NOW (dominant) │ the active surface card —
│ renders §4 by surface kind │ the ONLY primary-action zone
├──────────────────────────────┤
│ ③ WHAT WE HAVE │ trust panel: fact chips
│ ✓ Problem: water heater │ (confirmed / heard / pending)
│ ◌ Address: 114 Red River… │ chips carry a "fix" affordance
│ · Callback: pending │
└──────────────────────────────┘
Rules: exactly one primary-action zone (the NOW card); the facts band never contains primary actions except per-chip "fix"; transient alerts (stale tap, reconnecting) render as a slim banner between ① and ②, never as modals; at call_end the receipt replaces the whole column. Buttons ≥48px targets, ≥16px body text, 4.5:1 contrast, DTMF order preserved, aria-live="polite" on the NOW card, motion respects prefers-reduced-motion.
Fact states (answers Q2/Q3, encodes R8):
- heard (outline chip): the extractor produced it this turn (
collect_turn.slots_delta); provisional by design. - confirmed (solid chip, check): survived a read-back confirm or was consumed by a committed action.
- pending (ghost row): declared in the flow, not yet collected. No raw transcript on the caller page in v0 (OD-CS1 adopted); no confidences, no ASR fragments, no engineer telemetry — server-side filtering per R7, §6.
4. Screen designs by surface kind
- media (say): current script line (from asset
text), local progress bar animated fromduration_ms+started_at_seq. No controls in v0 ("repeat" is a later token). Taps on visible menu remnants during audio are legal — a tap during play is abarge_in {via: web_tap}. - menu (choose): one button per control, DTMF order. Label = words; a small keycap badge shows the digit ([1] Launches) so "press 2 for news" and the second button are visibly the same thing (answers Q5 — cross-modal coherence beats prettier labels). >6 controls: top 5 + "More options" expander. Tapped button → pending spinner state; card advances only on the next
surface_state(server truth, §9). - form (collect): widgets from slot types (phone→tel, date/time→native, enum→chips, text/address→field); slot checklist mirrors the facts band; a single Submit emits one atomic
submit. The voice path stays live — fields fill from speech in real time (slots_deltapopulates the form), and the caller can finish either way; first completion wins per the arbiter. Voice fallback on timeout is the flow's existing behavior, untouched (R9/I7). - progress (do): hold text from the compiled hold asset + spinner + elapsed; never tool args or internals (R7, OD-CS7). Deadline breach renders whatever the flow's failure route renders — the screen has no failure UI of its own.
- choice (do outcome / confirm): outcome frames or offers as buttons. Offer card contract (answers Q6): ordinal badge matching the spoken option number (1/2/3), weekday + date, start time large, duration/arrival-window secondary, business timezone shown compactly ("2:30 PM CT"); tap sends
{type: "tap", payload: {node, token: offer_id}}— the offer_id is the token (R10/I9). A "none of these work" control is rendered iff the flow's grammar has that token. - receipt (end): per companion-channel §6 — appointment, service, address, callback, tenant contact,
.ics; PII limited to what a confirmation needs; shareable by design (pinning is live-phase only). - challenge (commit): the OD-R6 protocol verbatim — 4-digit code rendered on this session only, "enter it on your phone keypad," 30s countdown, two attempts. v0 flows mark nothing commit-tier (OD-R1 stands; see §12).
- alert: stale tap → "That screen was out of date — use the current options below" + refreshed card; WS drop → "Still connected by phone" banner + silent retry; expired → expired page; second device → "This link is open on another device."
5. Control-event and active-surface contract
Adopt the brief's frames with these deltas:
surface_state (server→client), the single rendering input:
caller_context→ renamedfacts, shaped{confirmed: {...}, heard: {...}, pending: [...]}to carry the three-state model.- Add
alert: {kind, text} | null(server-driven alert band) andreceipt_urlon ended. audio {asset_id, text, duration_ms, started_at_seq}as specced — the read-along contract (§7).- Emitted on every
state_enter, everyslots_delta, and every fact-state transition; idempotent (client renders latest, keyed bydecision_epoch).
Client→server: tap and submit exactly as the brief's frames (client_seq, decision_epoch, node — R5). New third type: fix {slot} — the correction request (§9). No text type in v0 (OD-R3 stands).
Routing: all three land in the existing companion_control seam → InputEvent → the shared InputArbiter (build item: the live session must share one arbiter across voice/DTMF/tap — brief §10 item 5 — and _companion_spec_provider must return the active node's locked-choice spec, item 4). Nothing new; the seam exists and is tested.
6. Privacy, PII, token, role, session pinning
All inherited and restated as the checklist the implementation is tested against: server-side projection only (R7 — the caller stream never contains confidences, heard_open, tool args, or other calls' data); slot visibility is declarative — slot_types gains caller_visible: bool (default true) and caller_display formatting hints (phone formatting etc.), and the facts projection consumes them; tokens/roles/pinning per companion-channel §4 (pinning live-phase only; receipt shareable with minimal PII); wrong ?call= yields nothing (existing test stands); observer role gets the engineer stream, never this page.
7. Audio / read-along contract (answers Q14)
Pre-rendered assets are the timing spine (R12): menus, holds, confirms, read-backs, link-sent/failed, and safety prompts ship as compiled assets with text + duration_ms; the page renders the text and animates progress locally from started_at_seq — no audio streaming to the page, ever. Slot-TTS fragments render as the template text with bound values. The page never advances the NOW card ahead of the server frame (R3): trace may know the next state, but surface_state for epoch N+1 is withheld until the engine commits the transition — the existing say-watchdog already bounds that. Progress bars are cosmetic; epochs are truth.
8. Scheduler-offer rendering contract
Covered in §4-choice, plus: offers arrive in surface_state already shaped by the Do's tool_result (offer_ids + display fields — the page computes nothing about time); a tap that loses the re-validation race (offer taken mid-decision) is not an error surface — the flow's compiled offer_invalid → re-offer route (blueprint L0-16) drives the next surface_state, and the page just renders it; a caller volunteering a different time does it by voice in v0 — the screen offers no freeform time entry (R10; revisit with text events).
9. Error, stale-input, and correction behavior
- Stale/locked/invalid/duplicate: dropped server-side by the arbiter (
input_dropped), surfaced client-side per §4-alert. Buttons disable the instant a new epoch'ssurface_statearrives, shrinking the stale window to network latency. - Pending-not-optimistic (ruling): a tapped control shows a pending state; the card advances only on the server frame. Keeps screen/audio synchronized, makes stale handling coherent, and the ~300ms pending read is "the call heard you."
- Correction path (answers Q4), three tiers:
- v0 always: read-back confirm states — decline routes to re-collect (exists today).
- v0 when active: while a collect state owns a slot,
submitoverwrites it (typed slots only, OD-CS3). - Blueprint-provided: the
fix {slot}event routes iff the flow compiled afix_{slot}route — the booking blueprint skeleton ships these, so random-access correction arrives fleet-wide via blueprint certification, not per-flow work. Afixon a flow without the route →input_dropped {reason: invalid}+ alert "You can correct that at the confirmation step." The screen never teleports the call; the FSM owns flow.
10. Build order for the implementing Claude
Adopt the brief's §10 sequence with two re-orderings and the reasons: (4) live spec provider and (5) shared arbiter move ahead of (3) tap send — taps without a routing spec drop silently and prove nothing; wire the seam, then the buttons. And (1) active surface provider should emit the facts three-state shape from day one, even before the trust panel renders it, so RunRecords capture fact transitions from the first live test. Resulting order: surface provider → generic renderer (media/menu first) → spec provider + shared arbiter → tap path → submit + forms → trust panel → offers → receipt swap-in → fix routes (blueprint-gated) → evidence hardening.
11. Acceptance tests and RunRecord evidence
The brief's §11 list is adopted in full. Additions binding it to the Quality Contract: every acceptance run emits a RunRecord whose evidence includes rendered-surface ids + epochs (server-logged at emission, so "what the page showed" is provable without trusting the client), all input_dropped events with reasons, audio started_at_seq/duration vs. audio_done deltas (read-along sync evidence), and fact-state transition events. Parity evidence (answers Q15): L1 gains a companion persona — a scripted tap driver alongside the voice/DTMF personas — and parity means: same scenario driven by voice vs. taps yields identical route sequences, identical terminal, identical booked offer_id; the parity comparison is a Layer-1 invariant (INV: modality parity), so a divergence is a hard fail with class transport or flow_logic, not a judgment call.
12. Open decisions with defaults
| id | decision | default |
|---|---|---|
| OD-CS1 | Raw transcript | Off in v0; script text + facts only. Revisit only with a privacy design. |
| OD-CS2 | Link trigger | Offer-in-flow; add contextual offers at high-value states (entering address collect, offer presentation: "I can text you a link so you can see these times") as compiled blueprint states. Auto-send stays tenant-config, off. |
| OD-CS3 | Typed input | Typed submit for typed slots only; voice fallback mandatory (unchanged). |
| OD-CS4 | Second device | Pin live; receipt shareable with minimal PII (unchanged). |
| OD-CS5 | Branding | Theme tokens only {name, logo, color pair, contact}; compile-time contrast check on the tenant color pair (an inaccessible theme is an L0 error, not a support ticket). No renderer forks. |
| OD-CS6 | Commit challenge | Amended to match OD-R1: v1 booking confirms are tap-tier (read-back + explicit yes is the safety); challenge activates with cancel/reschedule/payment. The flip is a one-line artifact change (surface.tier: commit), designed and tested now, dormant. |
| OD-CS7 | Tool status | Caller-safe progress only (unchanged). |
| OD-CS8 | Web-only calls | Out of v0 (unchanged). |
| OD-CS9 | Correction scope | Tiers 1–2 in v0; tier 3 (fix_{slot}) ships with the booking blueprint, not per-flow. |
| OD-CS10 | Tap during audio | Allowed = barge-in (barge_in {via: web_tap}), consistent with DTMF barge-in. |
| OD-CS11 | Optimistic rendering | Never advance the card client-side; pending state only. |