LIVE (M3-authoritative) as of 2026-07-04
Audience: operators deploying or troubleshooting this feature
Related: docs/superpowers/specs/2026-07-04-space-profile-intake-design.md
(design), docs/codex/2026-07-04-progressive-web-intake-design.md
(parent design), riff-m1-failover-runbook.html
(the two-machine phone setup this feature runs on top of)
A caller on the Space Channel line (321-559-MARS) presses/says option
0 and, in one short back-and-forth, gives their name, agrees (or
doesn't) to be texted a link, reads back a verification code so we know the
phone number is really theirs, and says the space topics they want updates
on. If they say yes to the text, they get a link to a live web page. While
the call is still going, that page fills in with what they just said, word
for word. After the call, they can revisit the same page and edit anything —
their name, their topic list — and it saves.
The point: voice is fast but bad at exact text (spellings, long lists, corrections). The page is the "write it down properly" surface for whatever the call already collected.
Caller story, start to finish:
Compiled by riff/business_pack/importers/space_channel_widgets.py
into flows/space_channel_widgets.yaml option 0
(space_profile_intake). Do not hand-edit that YAML — it is
regenerated output and gets overwritten.
name → consent (yes/no) → [yes: send link+code → verify code] → interests
→ save → confirm-back → loop back to the main menu
If the caller says no to texting: name and interests are still collected and saved, nothing is texted, and there is no web page for that call.
/intake.html)These three environment variables gate the feature. Get any of them wrong and either the feature silently doesn't run, or M1 starts writing to a database it must never own.
| Variable | Value | Where | Why |
|---|---|---|---|
RIFF_INTAKE_STORE_AUTHORITY |
1 |
M3 .env ONLY. Never set on M1. |
Turns on the SQLite-backed /api/intake/* routes and makes
this machine the single writer of data/profiles/profiles.db.
Both machines load the same riff/phone/server.py code
(active-active failover), so without this gate M1 would silently create its
own separate profile database and fork the data away from M3. |
RIFF_INTAKE_INTERNAL_TOKEN |
<shared secret, same string on both machines> | M3 .env AND M1 .env, must match
exactly. |
The intake API's internal endpoints (session create, mark-verified,
transcript append) require this token in an
X-RIFF-Intake-Internal header. Voice-side code on M1 needs it
to authenticate its writes to M3's API. If the two machines' tokens don't
match, M1's voice saves are silently rejected (403) and fall back to the
local spool — everything looks fine on the call but nothing reaches the M3
database until the spool is manually flushed. |
RIFF_INTAKE_API_BASE |
http://100.127.252.53:8767 (the M3's Tailscale address) |
M1 .env only. |
Tells M1's voice-side intake client where to send its writes. M3
doesn't need this — it talks to itself over 127.0.0.1:8767. |
A misconfigured deploy (e.g., RIFF_INTAKE_STORE_AUTHORITY=1
accidentally set on M1 too) is the single most dangerous failure mode here:
it splits the profile data across two independent databases with no
reconciliation. Check this first if data looks like it's disappearing or two
different names show up for the same caller.
data/profiles/profiles.db
(SQLite, WAL mode) on the M3 only. Three tables:
intake_sessions (one row per intake attempt),
intake_answers (one row per field, with a version number so
stale writes can be rejected instead of silently overwriting newer ones),
and caller_identity (verified phone → name, shared across
every future vertical — not just Space Channel).data/profiles/spool/*.jsonl —
where a voice-side write goes if it can't reach the M3 API right away
(network blip, M3 briefly down). Each spooled write is tagged with an
idempotency key so replaying it twice can't double-apply it. The phone
server replays the spool automatically on boot (it waits for its own
/healthz to come up first, then flushes); nobody needs to
trigger this by hand in the normal case.# 1. Schema is being served (confirms the API routes registered — i.e.
# RIFF_INTAKE_STORE_AUTHORITY=1 took effect on M3):
curl -s "http://127.0.0.1:8767/api/intake/schema?flow_id=space_channel_widgets&capability=space_profile_intake"
# 2. Pull a real session by its token (from a texted link, or one you
# created for testing) — internal endpoints need the shared token:
curl -s "http://127.0.0.1:8767/api/intake/session?token=<token>"
# 3. Internal-only endpoints (session create, mark-verified, transcript)
# require the header:
curl -s -X POST "http://127.0.0.1:8767/api/intake/session" \
-H "X-RIFF-Intake-Internal: $RIFF_INTAKE_INTERNAL_TOKEN" \
-H "Content-Type: application/json" \
-d '{"call_id":"test","phone":"+15551234567","flow_id":"space_channel_widgets","capability_id":"space_profile_intake"}'
# 4. Logs — same location as the rest of the phone-node setup:
tail -f /tmp/riff-phone-node/phone-server.log | grep -i intake
A healthy M3 boot log line looks like registering the intake routes; a node that is intentionally NOT the authority (M1) logs "intake API not registered: this node is not the intake store authority" — that line on M1 is expected and correct, not a bug.
| Symptom | Likely cause | Check |
|---|---|---|
| Page stays locked forever, never unlocks | The code check on the call never passed (wrong code read back, or caller gave up after two tries and only got voice-only intake) | Pull the session (GET /api/intake/session?token=...) — if
locked: true persists, the call's verify step didn't complete.
Re-verify by calling back. |
| "Hello, 〈name〉" greeting missing on a repeat call | Caller identity wasn't populated (first call never got a verified name saved), or their caller ID is blocked/unavailable so the lookup can't run | Check caller_identity for the phone number; blocked caller
ID has no fix — it's a silent skip by design, not an error. |
| Saves from the call aren't showing up on M3 / page never updates | Either RIFF_INTAKE_STORE_AUTHORITY isn't 1 on
M3 (routes never registered), or the write is sitting in the spool because
M3 was briefly unreachable |
Curl the schema endpoint (above) to confirm routes exist; check
data/profiles/spool/*.jsonl for unreplayed lines; check the
internal-token match between M1 and M3. |
| M1 writes are silently rejected (403) | RIFF_INTAKE_INTERNAL_TOKEN differs between M1 and M3
.env |
Compare the two files directly; this fails closed (writes fall back to spool) rather than loud, so it's easy to miss. |
| Data looks split / two names for one caller | RIFF_INTAKE_STORE_AUTHORITY=1 was accidentally set on M1
too — now two independent databases exist |
Check M1's .env; it must NOT have this flag. Unset it and
restart the M1 node. |
consent:true marker currently only controls whether this
flow's own link+code text goes out. It does not yet gate every other
outbound SMS feature in the system — that wiring is tracked as a follow-up,
not shipped in v1.