Welcome Module (Intro State) — Design v1
Date: 2026-07-05
Status: Approved (operator direction), first slice queued
Relationship: riff runtime feature. Authored-content side and pipeline context live in the precursor repo (domain-tree-intake-nouns-for-riff: docs/pipeline.md).
Runtime pieces land in riff (pack schema + compiler + returning-caller hook);
the pack-level welcome block is generated by the tree-intake importer, so
verticals inherit it with zero per-vertical code.
1. Problem
Two defects at the most latency-sensitive moment of every call — pickup:
- Per-call TTS of the greeting is slow and wasteful. The caller's name is the only dynamic token, it never changes, and we re-synthesize it (plus the whole sentence around it) on every call.
- First-time and returning callers get the same front door. A returning, verified caller should not re-run the full intake; a first-time caller should be forwarded straight into it.
2. Decision (operator, 2026-07-05)
- A generic, reusable welcome state ("intro module") with attributes — not per-flow hand-authored greeting states.
- Whole-sentence pre-render: at first intake, once the caller's name is captured and verified, render the COMPLETE personalized line ("Hi David, welcome back to Austin Plumbing.") in the flow's voice and cache it. Never splice name fragments into templates — sentence-level prosody is native, word-level splicing sounds like a 1990s IVR ("You have… THREE… new messages").
- First call: generic line that forwards into the intake ("Thanks for calling Austin Plumbing — let's get you set up.").
- Later calls: play the cached personalized line instantly, then the main menu; the (profile) intake moves behind DTMF 0 (same reserved-zero convention the Space profile intake uses).
3. The module
3.1 Pack-level welcome block (attributes)
welcome:
business_name: "Austin Plumbing" # substituted into all lines
first_call_line: "Thanks for calling {business}. Let's get your details
set up — it takes about a minute."
returning_line_template: "Hi {name}, welcome back to {business}."
returning_menu_hint: "Press 0 anytime to update your details."
render_on: profile_save # when the personalized asset renders
opportunistic_render: sms_verify_deadair # bonus: render during code readback
stt_roundtrip_qa: true # verify TTS didn't mangle the name
fallback: generic # generic | live_tts when asset missing
auth_choice_gate: false # optional: 0 verify now / 1 guest before menu
Every attribute has a default; the tree-intake importer emits the block from the business fields, so a vertical gets the module without authoring anything.
3.2 Compiled shape
call start
│ CLID lookup (returning_caller hook)
├─ unknown caller ──► welcome_first (static generic audio)
│ └─► intake capability (the default route)
│ └─ profile saved ─► RENDER greeting asset
└─ known+verified ──► welcome_back (play cached per-caller asset)
└─► main menu (profile intake on DTMF 0)
welcome_firstandwelcome_backare compiler-emitted states, same pattern as the SMS sub-flow: one implementation, every pack gets it.- The returning branch NEVER synthesizes at call time: cached asset, or the configured fallback (generic static line by default — wrong-name-risk-free).
3.3 Asset lifecycle
| Stage | Behavior |
|---|---|
| Render | On profile_save (post-intake), and opportunistically during the SMS verification dead air — the caller spends ~10s reading the code; that is free render time. |
| Key | (voice_model, sha256(resolved_line_text)) in the existing static_audio manifest — the greeting is just another text asset; playback, barge-in, and caching need nothing new. |
| QA | STT round-trip: synthesize → transcribe → fuzzy-compare the name. On mismatch (the "Nguyen problem"), do not ship the asset; fall back to generic. Greeting someone by the wrong name confidently is worse than not personalizing. |
| Invalidate | Lazily on voice-model change or template change (key miss → re-render at next profile event). Nightly preflow-style job backfills all known profiles. |
| Delete | With the profile. The asset is derived PII. |
3.4 Sim-gate coverage (hard requirement applies)
- New personas:
first_time(no CLID match) andreturning(mocked CLID + cached asset present). - Deterministic invariants: returning path plays exactly one welcome line before the menu; no live TTS call on the returning path (asset-hit telemetry); DTMF 0 reaches the profile intake; first-call path lands in the intake capability without visiting the menu.
4. Non-goals (v1)
- Word-level audio splicing (rejected — see §2).
- Personalized lines beyond the greeting (readbacks stay live-TTS; they are mid-call where latency is cheaper and content is unpredictable).
- Cross-flow shared caller identity beyond what the profile store already gives (the asset is per business/voice, keyed by resolved text).
5. Sequencing
- riff: pack schema accepts
welcome:block; compiler emits the two states- CLID branch (behind the existing returning-caller hook).
- riff: render-on-profile-save job + static_audio registration + STT QA.
- Importer: emit default
welcomeblock for all four verticals; regenerate; sim gate green (new personas + invariants). - Precursor: none required for v1 (business_name flows from pack business
fields). A future
welcomeoverride could joinbusiness_overrides.
6. v1.1 — Verified vs Guest sessions at the welcome gate (operator, 2026-07-05)
Returning-caller welcome grows a trust handshake:
welcome_backplays the cached personalized greeting, then a second cached generic line: "I've just texted your follow-along link — when you're back at the menu, read me the six-digit code to unlock your profile — or say skip." The SMS (intake link + code) is sent automatically IFF the profile carries prior SMS consent from its original intake; without consent, no text, caller stays guest silently.- Non-gating code acceptance — AT THE MENU (state-driven honesty):
there is NO waiting state, and because the FSM is state-driven the code
is only heard where the interception lives: the menu. The spoken copy
says so ("when you're back at the menu"), never "anytime". While the
session is unverified AND a code was issued this call, the menu accepts:
- a spoken 6-digit code: match → "Access granted." → session_mode=verified → re-enter menu; mismatch → "That code didn't match — continuing in guest mode." → menu.
- an explicit decline ("skip", "no thanks", "not now", "later", "guest", "guest mode", "no code"; not bare "no") → "Continuing in guest mode." → menu, and the unlock offer is not mentioned again this call (declined flag). The caller may also just ignore it.
- Guest mode (
session_mode=guest, the default): read-only. Maps to the substrate's verify-before-write rule — profile mutations and saves are refused in voice with "proceeding in guest mode — say the code from your text anytime to unlock," and the web page stays locked exactly as the intake substrate already enforces. Verified mode unlocks both. - First-time callers are unchanged (their verification lives inside the intake capability as today).
Sim coverage: returning-verified persona (reads the code mid-menu → asserts "Access granted" + a successful profile mutation), returning-guest persona (ignores the code → asserts mutation refusal line + intact flow), wrong-code persona (asserts guest continuation, no lockout loop).
7. v1.2 — Auth choice gate before the menu (legal opt-in, 2026-07-06)
Some flows, especially law-office flows, should not play the full menu
immediately after texting the follow-along link. Set
welcome.auth_choice_gate: true to make the returning-caller path:
welcome_back
-> welcome_send_follow_along
-> welcome_follow_along_sent
-> welcome_auth_choice
0 / "verify" -> welcome_verify_code -> welcome_unlock_granted -> menu
1 / "skip" -> welcome_unlock_guest -> menu
The caller-facing line is short: "For your security, I texted your
follow-along link and a six-digit code. Press 0 to verify now, or press 1
to continue as a guest." The full menu is held until the caller either
verifies or chooses guest mode. This is currently enabled for
packs/legal_intake.pack.yaml and intentionally opt-in so cafe, trades, and
space-channel flows keep the lower-friction v1.1 behavior until reviewed.