RIFF — Companion Channel Design, v0 (texted live link)
Status: design handoff for the implementing Claude — open decisions marked OD-Cn
Date: 2026-07-01
Author: Fable 5 (design)
Depends on: docs/call-plan-artifact-schema-v0.md (trace §6, OD-6/OD-8) and docs/console-flow-map-design-v0.md (L1 corridor, <KeypadChips>, OD-M3)
Supersedes: OD-M3 ("mobile observer — build nothing yet") is promoted to a first-class channel by this doc. OD-6 (trace addressing/auth) is now a prerequisite, specified in §4.
0. What this is
During a phone call, RIFF texts the caller a short-lived link. Opening it shows a live, caller-framed view of the call that advances in real time as the call does: the current step, the menu as tappable chips, slots filling in during collection, calendar results as buttons, and a booking receipt at the end. Voice remains the primary channel; the page is a synchronized companion, not a replacement.
Two personas, one infrastructure:
| role | sees | can do |
|---|---|---|
| caller | caller-framed L1 (white-label vocabulary) | follow along; tap = input (day one) |
| observer | engineer L1 corridor (read-only) | follow along only |
Both are the per-call trace topic rendered by the L1-only mobile view already specified — the difference is the render mode and the token's role.
1. Why this is architecturally cheap here
Everything the page needs already exists in the two contracts: the artifact carries the menu (tokens, labels, order), every asset's duration_ms and script text_ref, and the Do outcome frames; the trace carries the cursor and every decision. The companion page is a third consumer of the same stream the console and the sims consume — no new data model, no per-page backend logic. The additions are: a link service (mint/validate tokens), an SMS send, one input path, and four trace event types.
2. Caller-view rendering by node type
The page is a single mobile column: header (business name, live dot), current step card, action area. It reuses <KeypadChips> — this is the component's third consumer (console keypad, corridor next-zone, companion).
- say — step title + the script text from
text_ref, revealed with a progress bar animated locally fromduration_ms(the trace says when audio started; the page needs no audio stream). This is also the accessibility story: the caller can read the briefing. - choose — the menu as tap chips in DTMF order, exactly the keypad. Taps route from day one (§5); the "press or say" hint remains since voice/DTMF stay first-class.
- collect — the slot checklist filling live from
collect_turnevents ("Water heater ✓ · This week ✓ · Callback number …"). This is the trust moment: the caller sees they were understood. No typed input in v0 (OD-C5). - do — spinner + hold text ("checking the calendar…"), then the outcome: enum frames render as buttons (three slot options as tap targets — the companion's highest-value tap), freeform frames as text.
- end — the receipt (§6).
Sync note: the trace leads the audio (state enters before its WAV finishes). The page must render the playing state with progress, never jump ahead of what the caller is hearing — the cursor is "current step," not "next step."
3. Sending the link
- Trigger (
OD-C1, default: offer-in-flow): a compiled offer on the hub choose — "I can text you a link to follow along — press star." Star (or "text me") fires the send and plays a prerendered confirm ("sent — check your messages"), then returns to the menu. Auto-send-on-answer exists as tenant config, default off (consent posture: someone who pressed star asked for the text; someone who merely called did not). - Transport: Telnyx Messaging API, sent from the same number the caller dialed (recognition + reply-ability). Texting is already operational on the Telnyx account — no registration lead time; the send is just a new call site.
- Message:
{tenant display name}: follow your call live → https://r.iff/c/{token}— short, branded, no marketing content (keeps the 10DLC campaign class simple). - Artifact delta: a per-flow
companionblock:{enabled, trigger: offer|auto, offer_on: [node_ids], sms_template_ref, receipt: {…}}. The offer's confirm audio is a compiled asset like any other (L0-validated).
4. Links, tokens, and the stream (specifies OD-6)
- Token: HMAC-signed, encoding
{call_id, role: caller|observer, exp, nonce}. Minted by a link service; the natural home in this stack is a Cloudflare Worker + KV (token → call routing), same pattern as the calendar OAuth worker. - One URL, whole lifecycle: the texted link never changes, and it always points at the Worker. Live phase: the Worker serves the real-time view (WS subscription to the trace stream) — S3 is never involved while the call is live. After
call_end: the engine renders the receipt (§6) as static HTML to S3 under an unguessable key; the Worker thereafter 302-redirects the same texted URL to that object. Observer-role links die atcall_end + 5 minregardless. - Expiry (decided): S3 lifecycle rule deletes receipt objects at 30 days; the Worker's token→key KV mapping carries a matching 30-day TTL, so an expired link renders a clean "expired" page from the Worker rather than a raw S3 404. Interaction on the receipt is links only (
.ics,tel:, future reschedule → a Worker endpoint) — taps are FSM input and exist only during the live phase. - Session pinning (
OD-C2, default on): the first device to open a token binds it (cookie/nonce); subsequent opens are rejected. A leaked SMS link then can't be watched by a third party. Cost: the caller can't move devices mid-call — acceptable. - Stream: WebSocket subscription to the per-call trace topic on the engine bus, gated by the token. The stream is already PII-light by construction (OD-8: transcripts and tool args by ref) — the companion page never receives refs it can't resolve; the caller view resolves only
text_refs of assets in its own call's artifact. - Role filtering at the engine, not the client: caller-role subscriptions receive a filtered event set (no L0 findings, no
heard_open, no confidences). Never ship engineer telemetry to the caller's browser and hide it with CSS.
5. Taps as input (ships in the first caller release)
A tap on a chip is a third deterministic input source, arbitrated exactly like DTMF vs. voice in locked_choice — first valid input wins, later inputs for the same decision are dropped.
- Path: tap → WS message
{token, node, token_id, client_seq}→ engine validates (role=caller, call live, cursor at that choose/do, token_id in its grammar) → injected as an input event → FSM routes. - Trace:
route_decisiongainsvia: web_tapin its enum. A tap during audio is a barge-in (barge_in via: web_tap). - Staleness guard: the tap carries the node it was rendered against; if the cursor has moved, the engine drops it and the page re-renders — no routing on a stale screen.
- Why this matters beyond convenience: for any caller who opens the link, tap input is ASR-immune — T1 vanishes for that population. And Do enum frames (slot offers) as buttons is the single best UX in the booking vertical: three buttons beat a read-aloud list every time.
6. The receipt (post-call second act)
For booking flows, call_end with disposition: completed freezes the companion into a durable receipt served at the same URL (§4): appointment time and address, what was booked, add-to-calendar (.ics), tenant contact, and a reschedule affordance (OD-C4: reschedule = call-back link in v0; a web reschedule flow is a later product). Receipt content is drawn from the trace's slot fills and tool_result — no new data. For the sellable product this receipt is the most visible artifact the tenant's customers ever see; it carries the tenant's branding (white-label render mode from OD-M2).
Storage (decided). Unguessable random S3 key + lifecycle rule deleting at 30 days. The two phases split cleanly by capability: the live phase must terminate at the Worker (only it can hold the WebSocket, validate tokens, and accept tap input — S3 is static and can do none of this); the receipt phase is pure static hosting, which is exactly what S3 is for. At call_end the engine renders the final receipt HTML (from the trace's slot fills and tool_result), writes it to s3://…/receipts/{256-bit-random}.html, and stores the token→key mapping in Worker KV with a 30-day TTL matching the lifecycle rule. The unguessable key is the auth for the receipt (session pinning applies to the live phase only — a receipt is deliberately shareable with a spouse or office manager, so keep its PII to what a booking confirmation needs). Presigned URLs are explicitly not used: no realtime, hard 7-day SigV4 cap, and no need — deletion enforces expiry.
7. Trace schema delta (amends §6 of the schema spec)
Four event types, plus one enum extension:
| type | payload | notes |
|---|---|---|
companion_link_sent |
role, channel: sms, to_ref, trigger: offer|auto |
to_ref, never the raw number (OD-8) |
companion_opened |
role, pinned: bool |
|
companion_closed |
role, reason: expired|call_end|error |
|
companion_input_rejected |
reason: stale|role|invalid |
the staleness guard, visible |
| (extension) | route_decision.via and barge_in.via gain web_tap |
8. Build order
Rides on trace-on-engine-bus (schema handoff step 1). Interactive ships in the first caller release — taps are not a later phase.
- P0 — plumbing: per-call trace topic over WS with token gating + role filtering (this also completes the console's live wiring and OD-6 — one job, three consumers).
- P1 — the caller release, interactive: link service (Worker + KV), SMS send via the in-flow offer, caller-framed page (say/choose/collect/do/end renderings, §2) with taps live (§5: input path, arbitration, staleness guard), observer role reusing the corridor. Demo: Space Channel for follow-along, austin_plumbing for tap-to-choose slots.
- P2 — receipt: same-URL freeze at
call_end, per-tenant TTL,.ics, tenant branding.
Acceptance:
- A1: On a live Space Channel phone call, press star → SMS arrives → open link → the page shows the current step and advances within ~1s of each state change, with say-progress matching what the ear hears.
- A2: A second device opening the same link is rejected (pinning), and an observer-role link for the same call shows the engineer corridor read-only.
- A3: On austin_plumbing, tapping a slot button routes the call — trace shows
route_decision via=web_tap— and a tap raced against a spoken answer resolves to exactly one route. - A4 (P2): A completed booking's texted link, opened days later, 302s to the S3 receipt with a working
.ics; opened after the 30-day lifecycle deletion, it renders the Worker's expired page (never a raw S3 404). The URL is the one from the original SMS.
9. Open decisions
| id | decision | default in v0 |
|---|---|---|
| OD-C1 | Send trigger | Offer-in-flow (press star); auto-send is tenant config, off. |
| OD-C2 | Session pinning | On. Revisit if device-switching complaints appear. |
| OD-C3 | Web-only calls | The companion page running without a phone leg (pure web session) is the existing LiveTransport story, not this doc — but the page and voice.html will converge eventually. Explicitly out of scope for v0. |
| OD-C4 | Reschedule from receipt | Call-back link only; web reschedule flow is a separate product decision. |
| OD-C5 | Typed input into Collect from the page | Deferred — it's a fourth input source with real design weight (free text racing voice). Schema unaffected today. |
| OD-C6 | SSE fallback | WS only in v0; add SSE if real-world networks demand it. |
| OD-C7 | Receipt storage | Decided: unguessable random S3 key + 30-day lifecycle rule; Worker 302-redirects the texted URL post-call; KV mapping with matching 30-day TTL for a clean expired page. Live phase never touches S3. |