Calendar Companion — Live Offer Mirror (v0 design)
Date: 2026-07-02 Status: design (scoped: "live offer mirror, tight"). Read-only; ships ahead of tap-to-control. Relationship: the visual read-only layer beside the voice calendar concierge. That spec does things (book/move/cancel, auth). This one only shows.
1. Goal
While a caller is on a booking call, the texted companion link gives them a live visual of exactly what the agent is offering — the slots the voice flow just read out, with the one currently being spoken highlighted. The caller still speaks their choice; the page is a supplementary aid, not a control surface.
─ Sunny Side Tutoring ─────────
The agent is offering:
○ Tue Jul 8 · 1:00 PM
● Tue Jul 8 · 2:30 PM ← just said
○ Tue Jul 8 · 4:00 PM
"Say which one works."
Why this is a separate, sooner deliverable. It is read-only and needs none of the gated remote-control pieces:
- no surface renderer (item 3), no transport swap (item 1) — those are only for tap-to-select;
- no OTP / mutation auth — showing the offer is not a mutation.
It's the companion trace page you can already watch follow a call
(scripts/companion_sim_server.py), rendering calendar offer info instead of
raw §6 events.
2. Data flow
OFFER state (voice flow) companion.html
propose_booking → offered slots renders the offer list,
│ highlights the "just said" slot
▼ ▲
emit `offer_shown` §6 event ──▶ PII-projection whitelist ──▶ /ws/companion
{slots:[{id,label,start_iso}], call_id} (offer fields added) (live push)
audio "speaking slot N" ──▶ `offer_highlight` event ──▶ page highlights row N
Everything rides the existing companion channel — /ws/companion +
companion.html + the PII-projection whitelist in
riff/phone/observer.py::_PUBLIC_TRACE_FIELDS. No new transport.
3. What has to be built (small, additive)
- Structured offer, not just a speakable string. Today
book_appointment_v1's OFFER state produces_scheduling_offered_speakable(a string). The page needs the structured offer:[{slot_id, label, start_iso}]. The cal-provider offer already has these (thescheduling_v1locked_choice path exposes_scheduling_choice_set); surface the same structure on thebook_appointment_v1path and emit it as anoffer_shown§6 event. - Whitelist the offer fields in
_PUBLIC_TRACE_FIELDS—offer_shown→{slots, node};offer_highlight→{index, node}. The offer is availability (business data), not caller PII, so it's safe on the public feed; it is still scoped to the call bycall_id(the companion capability). companion.htmloffer-mirror render mode — when anoffer_shownevent arrives, render the list; onoffer_highlight, mark the spoken row. Falls back to the existing trace view when no offer is active.- The "just said" highlight — derive
offer_highlightfrom the audio/ speaking event the agent already emits per spoken chunk (the same signalaudio_pushedcarries), mapped to the slot index.
4. Scope / privacy
- Read-only. No inbound frames, no auth — the caller speaks their pick; the voice flow does all acting. (Tap-to-select is a later layer that reuses the already-built control wire + the concierge's OTP model.)
- Call-scoped.
?call=<id>is the capability, exactly as today's trace companion. The offer feed carries no caller PII (no name/phone) — it's the availability the agent is already speaking aloud. - In sync by construction. Because the page renders only the current offer (not a browsable calendar), it can't drift from what the voice is doing — the reason "tight mirror" was chosen over a full dashboard.
5. Testing (the rigor bar)
Deterministic, no live call — the companion_sim_server / FakeCalendar pattern:
- projection test: drive the OFFER state; assert an
offer_shownevent is emitted with structuredslotsand that the PII projection keeps only{slots, node}(no name/phone leak) — the same whitelist discipline that guards the trace feed. - highlight test: a speaking event for slot N produces
offer_highlight {index:N}. - sync test: re-offer (caller says "different day") replaces the list; the page never shows a stale offer.
- render test:
companion.htmlshows the list + highlights the spoken row.
6. Non-goals (explicit)
- Tap-to-select — deferred; it reuses the built companion-control wire
(
route_control_event) + needs the concierge's decision-epoch wiring. - "My appointments" view — the richer "offer + your existing appointments"
option is deferred (needs the caller-ID-scoped
get_eventsread from the concierge spec's §2a owner marker). - Browsable calendar dashboard — rejected for v0: it risks drifting out of sync with the voice call.
7. Build order
- Surface the structured offer + emit
offer_shown(voice-side). - Whitelist offer fields (projection).
companion.htmloffer-mirror render + highlight.- Deterministic projection/highlight/sync tests.
Each step is independently testable; the whole thing is read-only and additive, so it can land without touching the gated remote-control items.