Build handoff Digital Receptionist redo · for Matt (backend) and Seth (frontend) · decided 2026-09-13: the hybrid home, her name is BellaOpen the prototypeWhere it rolls upKickoff doc

Digital Receptionist: build handoff

Everything needed to build what the prototype shows, on the worker and frontend that exist today: the message state machine, every screen element with its data source, the one new table and its routes, the frontend file plan with exact files and lines, copy strings, seed data, QA steps and acceptance criteria per wave. File:line references were checked against both repos on 2026-09-13; anything still unverified says "confirm".
1. What we are buildingdecisions and the five rules 2. Waves and donewhat ships when, acceptance per wave 3. The message state machinestates, derivation, transitions, due times 4. Screens, element by elementToday, drawer, All calls, OK, Settings, rollups 5. Backend: data and endpointsMatt: what exists, one new table, new routes 6. Frontend: file planSeth: routes, nav, pages, rollups, tests 7. Copy deckevery string, as decided 8. Seed data, QA, open questionslocal seed, acceptance checks, what to confirm
1 · What we are building

Four tabs replace nine. Every call becomes a message someone owns. Bella never closes one herself.

Decided by MJ, 2026-09-13: the hybrid home from the kickoff, the receptionist is named Bella (a per-business field, Bella is the default), and her open messages roll up into To Do's and the RC Dashboard. The prototype is the spec for layout, copy and behavior; this document is the spec for data and code. When they disagree, the prototype wins on words and layout, this document wins on data.

1
The unit is the message.Who called, when, number, what they wanted, what Bella did, what she promised, one obvious next button. The nine old areas are kept, renamed, and moved behind one Settings door. No new vocabulary: every word on screen is one a contractor's office says out loud.
2
Status first, worst first.Every receptionist page opens with "Bella is answering (616) 555-0100". Today's brief names the worst caller by name. The message pile sorts by how late the promise is, then what is due today, then oldest.
3
Every open message has one name on it and a due time.No owner yet shows "Nobody yet, take it" and counts for the business owner. Hand-off requires a done-by time and bounces back if missed. Owner and due time are what make a message a to-do.
4
Bella may set Booked, Handled and Spam. Only a person sets Done.Done is stamped with who and when, and with what happened (reached them / left a message / no answer). "Left a message" is not done: the message comes back at a chosen time.
5
What she said in your name is on the record.Each commitment she spoke gets its own line with a due time, a kept / overdue state, its source of authority (an approved answer, the calendar, a rule) or an "improvised" flag, a link to the second in the recording, and an append-only correction path. Wave 2, but the data model is designed for it from Wave 1.

Nothing is removed

Every control on the nine tabs survives (section 4.5 maps each). The nine /dashboard/receptionist/*.html routes keep resolving as redirects to their new homes so bookmarks, Academy links and the diagnostics "fix" hrefs keep working.

Nothing is faked

Wave 1 shows only fields the worker returns today plus one new table. The promise ledger and "said on the record" sources wait for their backend (Wave 2) and are not rendered until then; no placeholder rows.

Real data from line one

Illustrative data lives only in the prototype. The v3 build reads the worker; the local seed in section 8 gives QA a realistic day (ids prefixed demo-rc-, removable in one statement).

Three things the research changed from the kickoff. (1) The worker has no /sms/send or /sms/conversations (they 404; src/routes/telephony-feature-gates.test.ts:132-136 asserts it). Texting lives under the /dashboard/sms/* hub (threads + send, tables text_chat_threads / text_message_logs); Text back uses that. (2) "Questions she could not answer" is already half built in the worker: the agent's save_question tool writes a knowledge row with status='unanswered', source='ai_call', source_call_id and the caller's question (src/services/mcp/voice-handlers.ts:346-396), so the Today card and the What she knows banner are Wave 1; only the drafted answer and the "answered from your approved answer" stamp are Wave 2. (3) Today's post-call "summary" is not a model call: PostCallProcessor.generateSummary (CallBridge.ts:150) truncates the first caller line to 120 characters, and caller_intent has no writer. Wave 1 shows that line honestly as "what they said first" plus the lead fields the agent saved mid-call; a real one-line "what they wanted" is part of the Wave 2 extraction step.
2 · Waves, and what "done" means for each

Wave 1 is a frontend regroup plus one table. Wave 2 is the extraction step and the two loops. Wave 3 is audio and voice.

WaveShipsBackendFrontendDone when
Wave 1Today (status line, brief, approvals inline, messages worst-first with owner and due, questions she could not answer, handled collapsed, week line). Message drawer (message, what to do, recording, transcript, details, call back / text back / book / hand to / close, how-did-it-go). All calls (search, date + kind filters). Waiting on your OK (exact change from the stored payload, approve / decline / edit first, everything she did with stamps). Settings (7 sections, every control kept). Rollups (To Do's group, Dashboard card, "Needs you today").One table call_followups + 3 routes. A source_kind filter on the activity feed. is_spam / spam_reason / contact_id added to the calls list projection. receptionist_name on the profile. My-work emits source: "receptionist"; workspace-summaries and attention-items include Bella.4 tabs, 9 redirects + route ledger, new Today + drawer + calls + OK pages, Settings rail with the existing page bodies as sections, To Do's category, Dashboard card. Deterministic state derivation with unit tests.A first-time owner opens Today and, without help, knows whether Bella is on, who is waiting on them and for how long, and clears one message end to end (call back, outcome, closed with a stamp). Zero fields shown that the worker did not return. The acceptance checks in section 8 pass on the local seed.
Wave 2Extraction step (one-line "what they wanted", what she did, commitments). Promise ledger ("Promises made in your name" on Today, the promise section and "said on the record" in the drawer, overdue promises drive due times and the brief). Drafted answers for unanswered questions and the "answered from your approved answer" stamp. Repeat caller line. Hand-off bounce-back.A sibling of the post-call sentiment call writing call_commitments; a deterministic overdue check; published_by / published_at / use_count / last_used_call_id on knowledge; a prior-calls lookup; a scheduled reassign.Ledger table, promise pill on messages, sources + improvised chip + hear-it + correct-the-record in the drawer, drafted answer in the question card, stamps in What she knows.Every commitment Bella spoke on a seeded call appears with its due time within a minute of the call ending; overdue flips without a page reload; an unanswered question becomes a published answer in one click and the next test call uses it (verified by the transcript).
Wave 3Listen in on a live call. Read it to me (the brief in Bella's voice). Contradiction check.Live audio stream to the browser (provider dependent). Brief text generator + TTS. Deterministic comparison against approved answers and commitments.Listen in button live, voice control on the brief, contradiction card.Owner hears a live call without the caller hearing them; the brief plays; a seeded price conflict shows as a card.
Honest sizing: Wave 1 frontend about five to seven days (the feature is 18 files, 7,535 lines; the Settings regroup is mostly moving and relabelling), Wave 1 backend two to three days (table, three routes, one filter, three projection columns, my-work and dashboard feeds). Wave 2 about a week of backend (the extraction prompt, a commitments table, the overdue check, four knowledge columns) plus two days of frontend. Wave 3 is provider-dependent. Sizing assumes the local QA recipe in the v3 project-specsheet.md ("Visual QA") is used from day one.
3 · The message state machine

One state per message, derived deterministically, changed only by people (and Bella for three of them)

A message is a call (PhoneCall as normalized in app/features/receptionist/receptionist-logic.ts) plus, from Wave 1, one optional call_followups row. The state is computed in one pure function, deriveMessageState(call, followup, pendingActions, viewerTeamMemberId), unit-tested, used by Today, All calls, To Do's and the Dashboard so they can never disagree.

livecalls.status = 'in_progress' (the insert default; also what POST /calls/test writes). Shown in the status line only, never in the pile. Actions: Listen in (Wave 3), Take over (existing transfer, always to contractors.phone).
needsa person owes the caller something and nobody has closed it. Default for: status = 'voicemail', preferred_callback present, urgency high, an unanswered knowledge row for the call, a hang-up, a failed call, or a followup row with state open.
teamfollowup.owner_team_member_id is set and is not the viewer. Shown under "With the team" with the owner's name and due time.
approvala pending action with status = 'pending' whose customer_id matches the call's contact, or whose args carry the call id (confirm which is populated for receptionist actions). Shown with the approval card inline.
bookeda receptionist_appointments row with source_call_id = call.id and status not cancelled. Bella sets it.
handledstatus = 'completed' with nothing owed: answered from knowledge, handled_by = 'forwarded' and accepted, an appointment moved. Bella sets it.
spamis_spam = 1 (the list hides these unless excludeSpam=0). Bella sets it. Actions: unblock.
donefollowup.state = closed, with closed_by, closed_at, outcome. Only a person sets it. Reopen clears it.

Derivation for Wave 1 (deterministic, no model call)

state(call, fu, pending, viewer):
  if call.status == "in_progress"                            -> live
  if fu and fu.state == "closed"                             -> done
  if call.is_spam                                            -> spam
  if pending.some(p => p.status == "pending" and linksTo(p, call))   -> approval
  if fu and fu.owner_team_member_id and fu.owner != viewer   -> team
  if fu and fu.state == "open"                               -> needs
  if call.status == "voicemail"                              -> needs   (a listen and a call are owed)
  if unansweredQuestion(call.id)                             -> needs   (knowledge row status unanswered, source_call_id = call.id)
  if call.preferred_callback or call.urgency in ("high","urgent")   -> needs
  if hungUp(call)                                            -> needs   (try once today)
  if appointment(call.id)                                    -> booked
  if call.status == "completed"                              -> handled
  else                                                       -> needs   (failed / unknown: someone should look)

hungUp(call)  = duration_seconds < 15 and the transcript has no caller line (transcript is JSON or newline text, see normalizeTranscript)
kind(call)    = "Vendor" when the vendor skill fired (Wave 1: contact_type; confirm values) ; "Known customer" when contact_id ; "Unknown number" when caller_number is unnamed ; else "New caller"
afterHours    = created_at outside contractors.business_hours (profile), business time zone

Due time rules

SourceDueShown as
Wave 2: a commitment with a time ("within the hour", "Monday", "today")the commitment's due_at"Owed a call by 10:14 AM · 3 hours late"
Voicemail with a stated time ("call me in the morning"); Wave 1 uses preferred_callback when the agent saved one (it is free text: use it only when isIsoLike passes, else the next rule)parsed time, else 12:00 next business day"Owed a call this morning · late"
Any other needsend of the current business day (from the profile's business hours; fallback 5:30 PM business time)"Due by 5:30 PM"
How did it go: left a message / no answernow + 2 hours, editable"Back on your list at 3:15 PM"
Hand to a teammatechosen in the dialog (default end of day)"Rosa owns this, due 5:30 PM"

Overdue is computed on read (due_at < now) everywhere, never stored, so the four surfaces always agree. Wave 2 adds a scheduled check only for the bounce-back and the optional hold-the-line nudge.

Transitions (who, from what, to what)

ActionWhoWritesResult
Take itviewer with nav.receptionist editfollowup.owner = me (creates the row if none)needs (mine), shows "You own this"
Call backviewerfollowup.owner = me if none; "calling" is client state until an outcome is chosendial (tel: link); the message shows "How did it go?" until answered
Reached themviewerstate closed, outcome reached, closed_by, closed_at, note, attempt appendeddone; if a promise was overdue the ledger shows "Kept late" (Wave 2)
Left a message / No answerviewerstate open, due_at = now + 2h (editable), note, attempt appendedneeds, "Back on your list at 3:15 PM"
Hand to…viewerowner, due_at, note, handed_by, handed_at; notifyUser to the teammate with a link to index.html?open={callId}team; appears in the teammate's To Do's; Wave 2 reassigns to handed_by if due passes unclosed
Text backviewerthe /dashboard/sms hub send route (confirm path); logs on the threadstate unchanged; the text shows under What she did with the time
Book a visitviewer with nav.scheduling editPOST /dashboard/appointments with a picked slot and sourceCallId; followup closed with outcome bookedbooked; 409 appointment_conflict reads "A visit already exists for this time"
Close it (nothing owed)viewerstate closed, outcome nothing_oweddone
Reopenviewerstate open, closed_* cleared, reopened_byneeds
Approve / Decline / Edit firstviewer with editexisting POST /dashboard/pending-actions/:id/approve / /reject {reason} / PATCH /:id (editable_payload)approval resolved; message becomes handled (approved) or needs (declined, if something is still owed)
4 · Screens, element by element

Each element, where its data comes from, and which wave it belongs to

Open the prototype next to this. The "Build notes" toggle in its review bar shows the real-vs-sample labels and the "where the old tabs went" map. Column names below are the worker's (src/db/001-consolidated-schema.sql:31-44 for calls); the v3 client normalizes them in normalizePhoneCall.

4.1 Today (#/home in the prototype; /dashboard/receptionist/index.html in v3)

Today, above the fold. Status line, brief, the approval waiting, the messages worst first.
ElementDataWaveRules
Page head "Digital Receptionist" + "Bella answers (616) 555-0100 for Brightwater Plumbing & Heating."name: new profile column receptionist_name (default "Bella"); number: GET /phone/diagnostics.phone_number; business name: /auth/me contractor.businessName1"Call me (test call)" is a plain button (not red), opens the existing TestCallDialog (POST /calls/test {toNumber}). No "Config" button in the head.
Tabs Today · All calls · Waiting on your OK · Settingscounts: needs (derived), today's calls, pending actions (/summary.pendingActions)1Replaces the nine-entry RECEPTIONIST_TABS (receptionist-shared.tsx:98-108) rendered through PageMenu. Today = needs count (red when > 0), All calls = calls today, Waiting on your OK = pending count.
Status line green dot, "Bella is answering (616) 555-0100 · 10 calls today · [On the line: Jason Okafor · 2:14] Listen in / Take over · All checks pass · 1:05 PM"GET /phone/diagnostics (src/routes/phone.ts:767): overall_status active / warning / blocked, summary, checks[] with action {label, href}; calls today from GET /summary.callsToday; live call from GET /calls/active (calls/api.ts:87, excludes test/onboarding numbers)1active = green dot + "All checks pass" in green; warning = amber dot + "1 thing to look at" linking to Settings → Is she working?; blocked = red dot + "Not answering: {summary}" + the first failing check's action button. Listen in is Wave 3 (hide until then). Take over = POST /calls/:id/transfer behind canControlLiveCall (receptionist-logic.ts:187-199); it transfers to the owner's phone, say so in the dialog.
The brief "Brief from Bella for Dan · since 7:15 AM · Tue, Sep 15, 1:12 PM · Read the whole brief · Read it to me" + one paragraphcomputed client-side from the same lists (section 7 has the sentence grammar). "since" = the viewer's last visit to Today (localStorage per user, fallback midnight business time). "Read the whole brief" opens the long form at brief.html. "Read it to me" is Wave 3.1The sentence must name the most overdue caller first. With no overdue: "10 calls since 7:15 AM. 3 need you, none past a promise." Counts are bold; overdue counts are red.
Waiting on your OK (only when pending > 0)GET /dashboard/pending-actions?status=pending1Same card as 4.4. Always in the default scroll, above the messages. Receptionist-only: filter out rows with source_kind = 'meeting' / sourceMeetingId set (Meeting Studio actions live on their own page).
Messages chips: Needs you (n) · With the team (n) · Everything today (n) · "All calls and history"derived states over GET /calls?excludeSpam=0&limit=200 (today + any older call with an open followup) + GET /dashboard/followups?state=open1Default chip = Needs you. Everything today lists today's calls newest first, excluding live. Empty: "Desk is clear. Nobody is waiting on you."
A message row (left: caller, number, kind, open project, "2nd call", time · duration · after hours, owner chip; middle: what they wanted, what Bella did, promise line, due line, note, flags; right: buttons)caller_name / caller_number / caller_city / caller_state / duration_seconds / created_at / status / handled_by / ai_summary / service_type / service_description / urgency / preferred_callback / estimated_value / is_qualified_lead / contact_id / contact_type; after hours = created_at vs business hours; flags derived (Please call when needs or team, Urgent when urgency high or urgent, Wants an estimate when is_qualified_lead, Left a voicemail when status voicemail). "What they wanted" in Wave 1 = service_type + service_description when the agent saved a lead, else ai_summary (first caller sentence). Open project and "2nd call" are Wave 2.1 (promise, prior, project: 2)Sort: overdue by lateness, then due today, then oldest. Value chip reads "Bella's estimate $180-300" (estimated_value, string) and never a bare number. Buttons by state: needs = Call back (red only when overdue or urgent), Text, Hand to…, Close; team = "{Name} has it" + Open; approval = Approve / Decline / Open; done = "Done · Dan" + Open; booked = the slot + Open.
How did it go? strip on a message after Call backclient state until an outcome is chosen; then PUT /dashboard/followups/:callId1Options: Reached them (closes), Left a message (back at +2h, editable), No answer (same), Could not reach, hand it to someone (opens Hand to). A "What happened?" note box saves with the outcome. Must survive navigation within the session (keep it in a module-level store keyed by call id).
Questions she could not answer card ("She could not answer this. Your rule: add an answer asks first." + question + Answer it / We do not answer that)GET /knowledge-base rows with status = 'unanswered' (written today by saveQuestion, voice-handlers.ts:346: category faq, content '', source ai_call, source_call_id, context = the question); count from GET /knowledge-base/unanswered/count1 (draft: 2)"Answer it" opens the editor with the question as the title and an empty body (Wave 1) or her draft (Wave 2); saving with status answered publishes it (PUT /knowledge-base/:id). "We do not answer that" soft-deletes the row (DELETE /knowledge-base/:id) so it stops surfacing. Link to the call via source_call_id.
Promises made in your name table (Who · What she said · Where it stands · Owner · Hear it)call_commitments2Sorted overdue, due today, on the calendar, kept late, kept. "Kept · confirmed by Rosa 7:20 AM" under the pill. Not rendered in Wave 1.
Handled without you (count, Show / Hide) + This week so far linederived; week line from /summary (callsThisWeek, qualifiedLeads24h, voicemailsOpen) plus a booked-this-week count (appointments read, confirm) and promises (Wave 2)1Collapsed by default. The week line omits the promise fragment until Wave 2.

4.2 The message, opened (drawer, ?open=<callId>)

Greg Thornton opened: what to do first, the project fact, the message, the promise with its source, what she did, what she said on the record, recording and transcript.
ElementDataWaveRules
Header avatar, name, state pill, promise pill, owner chip, number · city · time · duration, Open in CRM, project link, "2nd call" lineGET /calls/:id (SELECT *, so contact_id and the lead fields are present); CRM link = /dashboard/crm/people/detail.html?id={contact_id}; project link and prior calls are Wave 21Owner chip is a button when unassigned ("Nobody yet · take it").
What to do box (bold next step + due line + note)Wave 1: a template by state ("Call {first name} back" / "Listen, then call back" / "Approve or decline the contact update"); Wave 2: the extracted next step1Always the first thing under the header for needs / team / approval.
From the project factPM: the next task on the linked project assigned in the next 7 days (GET /api/pm/tasks, confirm the filter)2Only when the call matched a project and the caller asked about it. Otherwise omitted.
The message (For, Date · time, From, Phone, Address / job site, Message + checked flags + edit, Taken by, Urgency)followup.owner (For), created_at, caller + kind, caller_number, caller_address or the project, the "what they wanted" line, flags (followup.flags JSON), "Taken by Bella" / "Voicemail (after hours)" / "Block list", urgency1Show only checked flags; "edit" opens the six checkboxes (Please call, Urgent, Wants an estimate, Left a voicemail, Will call again, Returned your call).
What Bella promised in your name (quote, pill, due, "Nobody has called yet." / kept-by, Hear it, Correct the record)call_commitments (text, due_at, state, kept_by, kept_at, transcript_offset_ms)2Hear it seeks the recording to transcript_offset_ms and highlights the line. Correct the record appends a correction (never edits) and offers: also text the caller, also fix the answer she reads from, note only.
Waiting on your OK card (inline)the pending action linked to this call1Same component as 4.4.
A question she could not answer cardknowledge unanswered row with source_call_id = this call1Publishing from here also updates the Today card.
Booked linereceptionist_appointments with source_call_id (read route: confirm; the scheduling feature's list endpoint)1Title, when, who (assignees), where.
What she did listWave 1: derived lines (matched to a contact when contact_id; forwarded when handled_by = forwarded; took a voicemail; saved a lead when service_type; booked when an appointment exists; sent a text from the SMS thread; took a question when an unanswered row exists). Wave 2: the extracted list.1Never empty: at minimum "Answered the call" / "Took a voicemail".
Said to the caller, on the record (quote, "From: {source}" or "Improvised, not from your answers", Hear it (m:ss), Correct the record)call_commitments rows of kind statement2Excludes the promise line (it has its own section).
Recording and transcriptGET /calls/:id/recording (calls/api.ts:104, streams with Range passthrough); transcript from the call row (JSON array or newline text; normalizeTranscript)1Bella's lines labelled with the receptionist name. Wave 2 highlights commitments.
Details she captured (Reason, Urgency, Bella's estimate, Lead, Best time to call, Called before)service_type / service_description, urgency, estimated_value, is_qualified_lead, preferred_callback, prior calls (Wave 2)1"Bella's estimate" label whenever the value came from the call, never a bare number.
Connected (Ask Rippler about this call, Add note to project)Rippler context = askRippleOpen() in app/features/ask-ripple/ask-ripple-bus.ts; PM note = the project comments routelaterRender only the ones with an API; never dead buttons. "Ask Rippler about this call" can ship in Wave 1 if the bus accepts a context payload (confirm).
Action barby state (section 3)1Red is used only for the primary action when the message is overdue or urgent, and for Approve.

4.3 All calls (#/calls; v3 history.htmlcalls.html)

ElementDataWaveRules
Search caller, number, what they wantedclient-side over the loaded list (GET /calls supports limit up to 200, offset, status, urgency, afterDate, excludeSpam)1Debounced, keeps focus. Server paging later; the list is 200 rows at most.
When Any time · Today · This week · Last week; Kind All · Needs someone · Leads · Booked · Handled · Hung up · Voicemail · Spamderived; Spam requires excludeSpam=0 and is_spam in the projection (add)1Hung up, Voicemail and Spam are separate filters.
Columns When (+ after hours) · Caller (+ "2nd call") · What they wanted (+ estimate) · What Bella did (+ Promised:) · Where it stands (state + promise + due) · Owner · Openas 4.11Owner shows "Nobody yet" in red for unowned needs. Every row opens the drawer. Footer: block-list link to Settings → Your number.
ExportCSV of the filtered rows1Client-side.

4.4 Waiting on your OK (#/approvals; v3 action-log.htmlok.html)

The pending card and "Everything she did", each with who decided and when.
ElementDataWaveRules
Rule note "She asks first before: updating a contact, adding or changing an answer, changing how she handles calls. Texts and tags are automatic."GET /autonomy-policy (src/routes/autonomy-policy.ts:40): the nine receptionist gates the PUT accepts (send_sms, save_skills, update_skills, delete_skills, add_kb_entries, update_kb_entries, update_contacts, tag_contacts + sms_budget_per_day)1Generated from the policy: gates that are off read "asks first", gates that are on read "automatic".
Approval card eyebrow "She wants to update a contact. Your rule: asks first." · title · the exact change verbatim · Why · See the call · "If you do nothing, nothing changes." · Approve / Decline / Edit firstGET /dashboard/pending-actions projection (pending-actions.ts:492-514): actionType, args (the immutable proposed change, JSON), reason, title, subtitle, kind, urgency, customerId, createdAt; editable_payload is the user-edited copy (PATCH /:id, pending-actions.ts:264-298)1The exact change is rendered from args / editable_payload per action_type by a small FE template map (update_contacts: field, old, new; send_sms: to + full body; add_kb_entries: title + full body; tag_contacts: the tag). Never truncated, never behind a details link. Edit first opens editable_payload, saves with PATCH, then Approve. "See the call" links via customerId (contact) until a call id is projected.
Everything she did (auto actions + past decisions, newest first, "Approved by Dan, Sep 1 9:40 AM" / "Declined by Rosa, Sep 10 3:25 PM: reason" / "Automatic, sent 8:11 AM")GET /dashboard/activity (src/routes/dashboard/activity.ts:35) reads agent_pending_actions only, with limit + cursor; rows carry status, approved_at, rejected_at, executed_at, cancelled_at, error. Add ?source_kind=receptionist (or exclude meeting) so Meeting Studio rows stop appearing, and project approved_by_user_id / rejected_reason so the stamp names a person.1One stamp per row: actor + time from the record, never a client guess. The rejection reason is shown when present.

4.5 Settings (#/setup/<section>)

How she handles calls (was Customer skills): plain names first, the key as a small grey label, on/off per row, editor with Advanced.
SectionWasKeeps (every control)Changes
Your numberPhone & SMS (receptionist-phone.tsx, 941 lines)current number + release (confirm), search by area code + provision, test inbound call, webhook verify + repair, outbound voice configure, sub-account, 10DLC brand + campaign forms, readiness rowsRows read: number (Active), Text messaging (Registered / the next step), Phone company connection (Connected / Repair), Block list (Manage; the spam list from is_spam rows and the block-list table, confirm). Registration forms render only while a step is incomplete. "10DLC" and "webhook" never appear as headings.
Who answers whenRouting (receptionist-routing.tsx)during / after modes, forward-to, rings, the business-hours builder, partial saves via PUT /phone/settings and PUT /contractors/profile {businessHours}Modes read "Bella answers right away / Ring my phone first, Bella backs up / Voicemail only". After hours "Then forward to" gains an "On call this week" picker from GET /api/team?fields=picker that fills afterHoursForwardTo with the member's phone. Saturday shows "Emergencies only" when the emergency policy article is published.
Greeting and voiceConfig (receptionist-setup.tsx)greeting, language, answer-after seconds, voice + preview, agent, enabled switch, the six capability switches, business identity read-only rowsAdds "Her name" (new profile column receptionist_name, default Bella). "ElevenLabs agent" moves under Advanced. Capability switches read as "What she is allowed to do on a call".
What she knowsKnowledge base (receptionist-faq.tsx + receptionist-faq-article.tsx)categories, articles, answered / unanswered status, reorder, delete, full-page editor with AI usage rulesAdds the questions-she-could-not-answer banner (unanswered rows, Wave 1), an on/off switch per answer (Wave 1: published ↔ empty content is the worker's gate; a real draft status needs a column, Wave 2), "Published by X, date" (needs published_by / published_at, Wave 2), "used N times · last: {call}" (use_count / last_used_call_id, Wave 2). Wave 1 shows "Not used on calls" for empty-body rows.
How she handles callsCustomer skills (receptionist-vetting.tsx)tree, keys, parents, conditions, activation, templates, prompt editor, sort order, load command (/customer-skills, table ai_skills)Plain name first, key as a grey mono label, condition as words, toggle per row, editor with "What she should do" + "Applies when" and an Advanced toggle for key / parent / sort / load command.
What she can do without askingAutonomy (receptionist-autonomy.tsx)the eight gates + SMS budget, review changes, saveEach row reads "Automatic" or "Asks first". Pending approvals are not on this page.
Is she working?Diagnostics (receptionist-diagnostics.tsx)overall status, every check with its fix link, run again, recent issues (recent_errors)Adds "Things to look at" above the plumbing checks (unanswered questions count). The overall answer also feeds the status line on every page. Check ids today: phone_provisioned, twilio_ownership, webhooks, ai_enabled, business_hours_configured, within_business_hours, during_hours_routing, after_hours_routing, forward_to.

4.6 Rollups: To Do's and the RC Dashboard (#/rollup)

The Dashboard brief counts her messages; the Receptionist card becomes the Bella card; her rows join "Needs you today". To Do's gets a Digital Receptionist group.
ElementDataWaveRules
Dashboard brief pill "need you" and the sentenceGET /dashboard/attention-items (src/routes/dashboard/attention-items.ts) gains receptionist items from open followups (owner = viewer or unassigned for the tenant owner)1The sentence names Bella's share: "5 from Bella (3 past a promise)".
Bella card (the Receptionist section that already exists in command-cockpit.tsx:72 and the v0 tile at home-classic-grid.tsx:32): "Bella is answering · 10 calls today", big number = need you, "3 past a promise", top 3 messages, footer "1 waiting on your OK · 1 booked today · Open Today"GET /dashboard/workspace-summaries gains a receptionist key (answering, callsToday, needsYou, overduePromises, pendingActions, bookedToday, topMessages[3]); the FE schema dashboardWorkspaceSummariesSchema (api-client-dashboard-home.ts:44-54) gains the key1Each row opens the message drawer on Today (index.html?open={callId}). The v1 HomeOverview grid (home-overview.tsx:104-124) has no receptionist card today; add a WorkspaceCard there too.
Needs you today rows with a "Bella" source chipattention-items, merged with the existing PM / CRM items, sorted by lateness1Same row grammar as the existing list (AttentionRow, home-overview.tsx:174).
To Do's group "Digital Receptionist" (count, "3 overdue", rows: next-step title, "Bella · Sun 2:15 PM call · promised: …", Open, owner avatar, due / overdue)GET /dashboard/my-work (src/routes/dashboard/my-work.ts:75) emits items with source: "receptionist", title = next-step template, context = "Bella · {time} call", dueAt, ownerUserId, deepLink = /dashboard/receptionist/index.html?open={callId}, editable true, status open / closed. FE: add "receptionist" to MyWorkSource + myWorkSourceSchema (api-client-productivity.ts:26-35, 50-81) and a row in MY_WORK_CATEGORIES (productivity-my-work-logic.ts:24-41; category order is section order) and to MY_WORK_DATA_SOURCES.1Unassigned messages show for the business owner with a "?" avatar ("Unassigned, defaults to you"). Hand-off moves the row to the teammate. The "Awaiting your approval" tile counts pending receptionist actions (my-work already has an approval source; confirm whether it includes agent_pending_actions). Checking the box in To Do's calls PUT /dashboard/followups/:callId {state:"closed", outcome:"nothing_owed"}; "Open" goes to the drawer.

4.7 The pile, one caller at a time (#/files; optional Wave 1 layer)

Karen's folder open on the desk: folder tabs on the left, the navy folder tab with the badge, the cards as header bars with the message open.
ElementDataWaveRules
Entry "Open the pile, one at a time" on Today; the band at the top names the worst overdue promise and opens that folderthe same lists as Today, sorted worst first1 (optional)Pure frontend: no endpoint the list view does not already call. Ship it after the list view works, as a second way in; MJ may make it the default landing.
Folder rows (left): one per person, a folder-tab shape in the state color, name, state, time, card count; the open one is navy and pulled outderived states; card count = which cards exist for that call1Click a row, or Left / Right, to open that folder. The list scrolls. A legend explains the tab colors.
The open folder (desk): a navy tab with avatar, name, state and promise pills, kind, time, duration, owner, and the DataRipple badge; a white body with the card chips and the cardsas the drawer1Changing folder flies the cards out and the next folder's cards in (staggered 140ms, translate + opacity only).
The cards as a column of header bars (title, that card's pills or link, the badge): the message slip, recording and transcript, the promise, the contact, then project fact, open bid, appointment, unanswered question or pending approval; each card keeps its own body stylethe drawer's own components, reused; bid and project facts are Wave 2 data1One card is open at a time and expands in place (max-height transition); the others are 44px header bars that slightly overlap. Hover a bar to peek (it expands over its neighbours), click it to open it, the chips or Up / Down do the same. The open card, Enter, or "Open the full record" opens the message drawer scrolled to that section. Cards for data that does not exist yet are simply absent.
Desk bar with the current caller, owner chip, folder count, previous / next and the same action buttons as the messageas the drawer1An outcome chosen here (reached them, hand to, close) advances to the next folder after the cards fly out.
5 · Backend: data and endpoints Matt

Reuse everything that exists, add one table and three routes, widen three projections

5.1 What exists (verified, file:line in ripple-receptionist-worker/src)

EndpointUsed forChange needed
GET /calls (routes/calls/api.ts:33)the message listProjection (api.ts:66-71, 23 columns) lacks contact_id, is_spam, spam_reason, callback_number. Add those four. Keep excludeSpam default; the dashboard passes excludeSpam=0 for the Spam filter.
GET /calls/:callId (api.ts:164), GET /calls/active (api.ts:87), GET /calls/:callId/recording (api.ts:104)the drawer, the live call, the playernone
POST /calls/:callId/transfer (api.ts:181), POST /calls/:callId/cancel (api.ts:233), POST /calls/test (calls/test.ts:25)Take over, End call, Call me (test)none. Transfer always targets contractors.phone; the dialog says "to your phone".
GET /summary (routes/summary.ts:30)status line counts, week linereturns callsToday, callsThisWeek, voicemailsOpen, pendingActions, qualifiedLeads24h. Add needsYou (open followups + derived needs), bookedToday, unansweredQuestions; Wave 2 adds overduePromises.
GET /phone/diagnostics (routes/phone.ts:767)status line, Is she working?none (overall_status active / warning / blocked; checks with action.href; the hrefs must point at the new Settings sections after the route change, see 6.1)
GET /dashboard/pending-actions (routes/dashboard/pending-actions.ts:44), POST …/:id/approve (:113), POST …/:id/reject {reason} (:450), PATCH …/:id (:264)Waiting on your OK; Edit firstthe projection already carries args (immutable proposed change), title, subtitle, kind, urgency, customerId, sourceMeetingId. Add editable_payload, approved_by_user_id, rejected_reason, source_kind, action_class to the projection (columns exist, 001-consolidated-schema.sql:321-338). Confirm how a receptionist action links to its call (args? add source_call_id if not).
GET /dashboard/activity (routes/dashboard/activity.ts:35)Everything she didreads agent_pending_actions only, no source filter, so Meeting Studio rows appear on the receptionist page. Add ?source_kind= and ?exclude_source_kind=meeting; project the actor columns above.
GET / PUT /autonomy-policy (routes/autonomy-policy.ts:40, 61)What she can do without asking; the rule notenone (PUT accepts the nine receptionist fields in BOOLEAN_FIELDS + sms_budget_per_day)
POST /dashboard/appointments (routes/dashboard/appointments.ts:69)Book a visitbody {title, contactId, attendeeContactIds, assigneeUserIds, serviceType, notes, status, startsAt, endsAt, sourceCallId}, gate nav.scheduling:edit humanOnly + idempotency, 409 appointment_conflict. Add a free-slot read (GET /dashboard/appointments/slots?days=7&assignee=) reusing whatever the voice booking tool calls; without it the dialog takes a typed time.
/dashboard/sms/* hub (threads + send; tables text_chat_threads, text_message_logs; per the worker's .claude/CLAUDE.md)Text back; the text thread on the messageConfirm the exact send and thread paths. POST /sms/send and GET /sms/conversations do not exist (404, asserted by routes/telephony-feature-gates.test.ts:132-136); routes/sms.ts holds only the Twilio inbound/status webhooks.
GET /knowledge-base (routes/knowledge-base.ts:23), GET /knowledge-base/unanswered/count (:45), POST (:74), PUT /:id (:124), DELETE /:id (:186), POST /reorder (:226)What she knows; the question looptable knowledge_base (001-consolidated-schema.sql:100): status answered / unanswered, source manual / ai_call, source_call_id, context. The agent writes unanswered rows today (services/mcp/voice-handlers.ts:346-396, dedupes on title). Wave 2 adds published_by, published_at, use_count, last_used_call_id, draft_answer; the knowledge gate (services/elevenlabs.ts:478-482, answered + non-empty) increments use_count when it loads an article into a call.
/customer-skills (routes/customer-skills.ts:74-245, table ai_skills)How she handles callsnone
/contractors/profile, /voices, /voices/preview, /agents, /phone/*Greeting and voice, Your numberAdd receptionist_name to the contractor profile (ALTER migration, default Bella) and read it in the greeting default ("Thanks for calling {business}, this is {name}. How can I help?").
GET /api/team (routes/pm/team.ts:807, ?fields=picker)Hand to… picker, On call this weeknone (returns members[] with id, name, role, phone via rowToApi)
notifyUser(env, contractorId, kind, payload, {clerkUserId}) (services/push.ts:315)the hand-off notificationwrites user_notifications + push + email under preferences. Add a PushKind member receptionist_handoff with title "Bella: {caller} handed to you by {name}", body = the message line, link = /dashboard/receptionist/index.html?open={callId}. Never INSERT the table directly (push.ts:303-314).
GET /dashboard/my-work (routes/dashboard/my-work.ts:75), GET /dashboard/attention-items, GET /dashboard/workspace-summariesTo Do's, DashboardAdd receptionist items / keys as described in 4.6. No personal to-do store exists (the only tasks table is project-scoped, 001-consolidated-schema.sql:802), so the followup row is the to-do.

5.2 The one new table (Wave 1): call_followups

Migration file src/db/YYYYMMDDHHMMSS-call-followups.sql claimed with pnpm make:migration "call followups" (scripts/make-migration.mjs; numeric prefixes are frozen at 685, INVARIANTS.md:56-58). One CREATE TABLE per table (INVARIANT #2), never edited after apply (INVARIANT #3, migration-immutability-check.yml). contractor_id TEXT NOT NULL REFERENCES contractors(id) as DOCS/architecture/ADDING_A_FEATURE.md:111-113 prescribes. Every mutation goes through appendAuditLog(env, contractorId, 'call_followups', id, row) (services/audit-log.ts).

CREATE TABLE IF NOT EXISTS call_followups (
  id                     TEXT PRIMARY KEY,
  contractor_id          TEXT NOT NULL REFERENCES contractors(id),
  call_id                TEXT NOT NULL UNIQUE REFERENCES calls(id),
  state                  TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','closed')),
  owner_team_member_id   TEXT,                       -- team_members.id; NULL = unassigned (shows for the owner)
  due_at                 TEXT,                       -- ISO 8601; NULL = end-of-business-day rule on read
  outcome                TEXT CHECK (outcome IN ('reached','left_message','no_answer','booked','nothing_owed','handed')),
  note                   TEXT,                       -- latest "what happened?" text
  flags                  TEXT,                       -- JSON array of the six slip checkboxes
  handed_by_user_id      TEXT, handed_at TEXT,       -- last hand-off (Clerk user id)
  closed_by_user_id      TEXT, closed_at TEXT,       -- the human stamp; never written by the agent
  reopened_by_user_id    TEXT, reopened_at TEXT,
  attempts               TEXT NOT NULL DEFAULT '[]', -- JSON [{at, by, outcome, note}] so "called 1:15, no answer" survives
  created_at             TEXT NOT NULL,
  updated_at             TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_call_followups_open ON call_followups (contractor_id, state, owner_team_member_id, due_at);
Route (new file src/routes/dashboard/followups.ts, mount next to my-work in src/index.ts)Body / responseRules
GET /dashboard/followups?state=open&owner=me|all|<id>rows for the tenant joined with the calls projection (caller_name, caller_number, ai_summary, service_type, status, created_at) so Today, To Do's and the Dashboard need one readrequireFeature('nav.receptionist','view'). Resolve "me" to the caller's team member id the way my-work does.
PUT /dashboard/followups/:callIdpartial { state?, ownerTeamMemberId?, dueAt?, outcome?, note?, flags? }; creates the row if missing; appends to attempts when outcome is reached / left_message / no_answer; stamps closed_by_user_id / closed_at from the Clerk principal when state becomes closed; clears them and sets reopened_* when it becomes open again; rejects any closed_by in the bodyrequireFeature('nav.receptionist','edit') {humanOnly:true} so the agent's worker JWT can never close a message. Idempotency middleware like appointments. Audit log on every write.
POST /dashboard/followups/:callId/handoff{ ownerTeamMemberId, dueAt, note } → sets owner + due + handed_by / handed_at, appends an attempt of kind handed, calls notifyUser for the teammateedit, humanOnly. Wave 2: a scheduled reassign to handed_by when due passes unclosed.

CI to expect: add the new route dir entries to regression-map.json and tests/perf/smoke-all-features.js (regression-sync.yml), refresh scripts/schema-baseline-migrations.json, run scripts/preflight-migration-sync.mjs, and rebase right before pushing (the immutability check diffs against main's tip). Update DOCS/business-rules/receptionist.md (the tab strip paragraph is now wrong) and DOCS/architecture/SCHEMA.md.

5.3 Wave 2: extraction, commitments, drafted answers

CREATE TABLE IF NOT EXISTS call_commitments (
  id TEXT PRIMARY KEY, contractor_id TEXT NOT NULL REFERENCES contractors(id), call_id TEXT NOT NULL REFERENCES calls(id),
  kind TEXT NOT NULL CHECK (kind IN ('promise','statement')),
  text TEXT NOT NULL,                       -- the sentence as spoken
  due_at TEXT,                              -- parsed deterministically from the due phrase, business time zone; NULL for statements
  state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','kept','kept_late','released','scheduled')),
  kept_by_user_id TEXT, kept_at TEXT,
  source_kind TEXT CHECK (source_kind IN ('knowledge','calendar','rule','improvised')),
  source_ref TEXT,                          -- knowledge_base.id, rule key, or receptionist_appointments.id
  transcript_offset_ms INTEGER,             -- for Hear it
  corrections TEXT NOT NULL DEFAULT '[]',   -- append only: [{at, by, text, action}]
  created_at TEXT NOT NULL
);

5.4 Cross-cutting

Tenant scope and audit

Every new table carries contractor_id; every read filters by it; every write goes through appendAuditLog. There is no tenant-export catalog in the repo (the kickoff's memory note on that was wrong); the real requirements are the tenant-scope convention and the audit log (DOCS/architecture/CODE_HYGIENE.md:136).

Permissions

Read: nav.receptionist view. Take / outcomes / hand / close / reopen / approve: nav.receptionist edit (receptionistCanEdit, fails closed). Take over: receptionist.live-call-control admin. Book: nav.scheduling edit. Release number and delete call keep their gates. All followup writes are humanOnly.

Status vocabulary to honor

calls.status: in_progress, completed, failed, voicemail (forwarded is a handled_by value, not a status). handled_by: ai, forwarded, voicemail, human. Pending actions: pending, approved, rejected, executed, cancelled. Knowledge: answered, unanswered.

6 · Frontend: file plan Seth

Regroup the existing pages, add four files, keep every endpoint call

6.1 Routes and nav

FileChange
app/routes.ts:527-585Inside the dashboard-shell layout the 14 receptionist entries become: index.html (Today, receptionist-index.tsx), calls.html, ok.html, settings.html + settings/:section.html, brief.html, call.html (kept, full-page fallback), faq-article.html (kept, the editor). The old paths become a target map redirect module modeled on app/features/pm/pm-legacy-html-redirect.tsx (LEGACY_TARGETS at :33-51, loader :53-57): history → calls; action-log → ok; routing → settings/hours; faq → settings/knowledge; vetting → settings/skills; phone → settings/number; autonomy → settings/permissions; setup, config, scheduling → settings/greeting; diagnostics → settings/health. The bare-section redirect at routes.ts:170 stays.
scripts/parity/route-ledger.json:1219+Update every receptionist entry's newPath / status; pnpm check:route-ledger gates the PR.
app/features/dashboard-shell/dashboard-sidebar-nav-data.ts:180-195The receptionist entry's href becomes /dashboard/receptionist/index.html (today it is history.html); subItems become Today, All calls, Waiting on your OK, Settings. Count badges do not exist on sidebar items today (only SettingsShellNavItem.badge does); the prototype's sidebar badges are optional, ship without them in Wave 1.
Diagnostics fix hrefsThe worker's checks carry v2 hrefs mapped by reactHref in receptionist-diagnostics.tsx; with the redirects in place they keep working. Update the worker's hrefs to the settings sections when convenient.

6.2 Receptionist feature files (app/features/receptionist/)

FileChange
receptionist-shared.tsx (404 lines)RECEPTIONIST_TABS (:98-108) becomes four entries with count badges; ReceptionistPageHead (:119) gains a status-line slot; keep useReceptionistBootstrap (:51), receptionistCanEdit (:85), TestCallDialog (:181), ReceptionistCard (:344), ReceptionistRowLine (:379). Add StatusLine, MessageRow, OwnerChip, DueLine, ApprovalCard, HowDidItGo.
receptionist-logic.ts (1124 lines)Add deriveMessageState, messageDue, isOverdue, sortWorstFirst, briefSentence, nextStepTemplate, slipFlags, isHungUp, callerKind, proposedChangeRows(action) (the args template map), and the plain-English label maps. All pure, all covered in receptionist-logic.test.ts (532 lines today; the perms({...}) factory and the permission cases at :159-183 and :287-305 are the pattern).
api-client-receptionist.ts (699 lines)Add fetchActiveCalls (/calls/active), fetchFollowups, updateFollowup, handoffFollowup, fetchSlots, sendText + fetchTextThread (the /dashboard/sms paths, confirm), fetchTeam (/api/team?fields=picker&clerk=skip), fetchUnansweredQuestions (filter of fetchKnowledgeArticles), patchPendingAction (PATCH editable_payload); extend fetchPendingActions / fetchActivityPage with the source filter and the new projected fields.
receptionist-index.tsx (Today)Rewritten: loads calls, active calls, followups, pending actions, summary, diagnostics and unanswered questions in parallel with 8-second timeouts and partial-failure toasts (the withTimeout + Promise.allSettled pattern in receptionist-action-log.tsx); renders the sections in 4.1; reads ?open= to open the drawer.
receptionist-message-drawer.tsx (new)The drawer in 4.2 using the shadcn Sheet; reuses the recording object-URL logic from receptionist-call-detail.tsx (revoke on close). call.html stays as the full-page fallback and keeps Delete call (canDeleteCall) and End call.
receptionist-history.tsx → All callsSearch, when and kind filters, owner column, rows open the drawer.
receptionist-action-log.tsx → Waiting on your OKReplaces the five tiles and "Approval gates represented" with the rule note, approval cards and "Everything she did" (source-filtered feed with stamps). Keep the generation-counter pagination.
receptionist-settings.tsx (new)A local rail + panel switch mirroring settings-hub.tsx (settingsPanelFromHash / settingsRailKey pattern in settings-hub-logic.ts) with SettingsCard from app/features/settings/settings-shared.tsx:96-150. Do not reuse SettingsShell (app/components/ui/settings-shell.tsx:222) itself: its props are account / company scoped. Each section renders the existing page body (routing, setup, faq, vetting, phone, autonomy, diagnostics) as a component with the new headings; the existing files stop being routes.
receptionist-brief.tsx (new, small)The long-form brief (layout B) at brief.html.

6.3 Rollups

FileChange
app/features/productivity/api-client-productivity.ts:26-35, 50-81Add "receptionist" to MyWorkSource and myWorkSourceSchema.
app/features/productivity/productivity-my-work-logic.ts:24-41, 50Add the category row { key: "receptionist", label: "Digital Receptionist", short: "Bella", icon: "headset", accent: "#CC0C1F" } (order = section order; place it after Project Management) and add the key to MY_WORK_DATA_SOURCES. Checking a row calls updateFollowup(callId, {state:"closed", outcome:"nothing_owed"}) through the existing controlForItem path.
app/features/dashboard-home/api-client-dashboard-home.ts:44-54Add the receptionist key to dashboardWorkspaceSummariesSchema.
app/features/dashboard-home/command-cockpit.tsx:72, home-overview.tsx:104-124, home-classic-grid.tsx:32The Receptionist section / tile becomes the Bella card (4.6) in all three templates (v2 has the section, v1 needs a WorkspaceCard, v0 has the tile). "Needs you today" rows come from attention-items unchanged.

Grammar to reuse

SettingsCard, PageMenu (app/components/ui/page-menu.tsx), shadcn Badge / Button / Dialog / Switch / Sheet, Sonner toasts. No new design tokens: the prototype uses only the shipped palette (red on white, navy rail, Ubuntu).

Tests (vitest, next to the source)

  • receptionist-logic.test.ts: deriveMessageState one case per derivation row plus precedence (closed beats everything; approval beats team); sortWorstFirst; briefSentence for zero / one / two / three-plus overdue with singular and plural; messageDue rules; proposedChangeRows per action_type.
  • Redirects: copy app/features/dashboard-shell/dashboard-index-redirect.test.ts (the redirectFor() helper) for the nine old paths.
  • Rendered pages: // @vitest-environment jsdom + the Clerk vi.mock pattern from dashboard-topbar.test.tsx:1-14; assert Today's chip count equals its rows, the approval card shows the full change, the drawer action bar per state. This will be the first .test.tsx in the receptionist feature.
  • Run pnpm typecheck then pnpm test serially (repo rule).

Do not

  • Show any field the worker did not return (no placeholder promises in Wave 1).
  • Let the agent's worker JWT close a message (all followup writes are humanOnly).
  • Truncate the proposed change on an approval card.
  • Use red for anything that is not overdue, urgent, or the Approve action.
  • Ship the "Build notes" chrome (real / sample / where-tabs-went); that is prototype-only.
7 · Copy deck

Every string, as decided. Plain English, no em dashes, no invented words.

WhereString
TabsToday · All calls · Waiting on your OK · Settings
Sub-line{Name} answers {number} for {business}.
Head buttonCall me (test call)
Status, on{Name} is answering {number} · {n} calls today · All checks pass · {time}
Status, warning{Name} is answering {number} · 1 thing to look at
Status, blocked{Name} is not answering: {diagnostics summary} · Fix it
Status, liveOn the line: {caller} · {m:ss} · Listen in · Take over
Brief headBrief from {Name} for {first name} · since {time} · {date, time} · Read the whole brief · Read it to me
Brief, overdue{Caller A} has waited {lateness} for the call I promised {when}, {Caller B} {lateness}. They are {k} of the {n} that need you out of {calls} calls since {time}. {q} question(s) I could not answer. {c} change(s) wait on your OK. {h} caller(s) hung up before saying anything. {b} booked, about {value}. {m} handled without you.
Brief, none overdue{calls} calls since {time}. {n} need you, none past a promise. …
Brief, clearNobody is waiting on you. Nothing I could not answer. Nothing waits on your OK.
Messages headMessages · Worst first. A person closes each one.
ChipsNeeds you {n} · With the team {n} · Everything today {n} · All calls and history
Empty pileDesk is clear. Nobody is waiting on you.
Owner chipsYou own this · {First name} owns this · Nobody yet · take it
Due linesOwed a call by {time} · {lateness} late · Owed a call this morning · late · Due by {time} · Back on your list at {time}
KindsKnown customer · New caller · Vendor · Unknown number · open project · after hours · 2nd call this month
FlagsPlease call · Urgent · Wants an estimate · Left a voicemail · Will call again · Returned your call
ValueBella's estimate {range} · Bid {amount}
Row buttonsCall back · Text · Hand to… · Close · Approve · Decline · Open · Listen in · Take over
WhereString
How did it goYou called {caller} at {time}. How did it go? · Reached them · Left a message · No answer · Could not reach, hand it to someone · What happened? (e.g. wife says try after 3)
ToastsDialling {number} from your phone. Tell {Name} how it went when you are done. · {caller}: reached. Closed by {you} at {time}. · {caller}: left a message. Back on your list at {time}. · {caller} handed to {teammate}, due {time}. They were notified. It comes back to you if it is not closed. · Approved by {you} at {time}: {title}. Done now. · Declined by {you} at {time}: {title}. Nothing changed.
Hand toHand {caller} to a teammate · They get the message, the recording and your note. Their name goes on it until they close it. If it is not closed by the time below, it comes back to you. · Needs to be done by · Note to them · Hand it over
Drawer sectionsThe message · What {Name} promised in your name · Waiting on your OK · A question she could not answer · Booked · What she did · Said to the caller, on the record · Recording and transcript · Details she captured
Drawer fieldsFor · Date · time · From · Phone · Address / job site · Message · Taken by · Urgency · Reason · Bella's estimate · Lead · Best time to call · Called before
SourcesFrom: {article}, published {date} · From: the calendar ({calendar}) · From: your {rule} rule · Improvised, not from your answers · Hear it ({m:ss}) · Correct the record
Approval cardShe wants to {kind}. Your rule: asks first. · Why: {reason} · See the call · If you do nothing, nothing changes. · Approve · Decline · Edit first
StampsApproved by {name}, {date time} · Declined by {name}, {date time}: {reason} · Automatic, sent {time} · Done by {name} at {time} · Published by {name}, {date} · Answered by {name}, {date time}
Settings sectionsYour number · Who answers when · Greeting and voice · What she knows · How she handles calls · What she can do without asking · Is she working?
Routing modes{Name} answers right away · Ring my phone first, {Name} backs up · Voicemail only · On call this week · Rings before she takes over
GatesAutomatic · Asks first · Daily text limit
KnowledgePublished · Not used on calls · Needs an answer · used {n} times this month · last: {call} · Answer it · We do not answer that · Publish answer
Health{Name} is answering calls. · Things to look at · Everything that must be true for her to answer · Pass · Blocked · Fix · Run again
To Do's / DashboardDigital Receptionist (group) · {n} overdue · Bella · {time} call · promised: "{promise}" · Needs an answer first · Unassigned, defaults to you · Bella is answering · {n} calls today · need you · {n} past a promise · Open Today
8 · Seed data, QA, open questions

The prototype's day is the seed. The prototype's checks are the acceptance test.

Local seed (ids demo-rc-*)

Load into local D1 for the demo tenant (contractor_id = org_3DgLsRxn8CR779N3XcS3saSBYEZ, the same tenant the PM demo seed uses) the eleven calls in the prototype's data.js: one in_progress, one hang-up (duration 6, no caller line), one answered from knowledge, one reschedule with a pending update_contacts action (args carrying the old and new number), one call with an unanswered knowledge row (category faq, source ai_call, source_call_id), one call matched to a project with preferred_callback, one vendor call handed to Rosa (a followup row with owner = Rosa), one booked estimate with a receptionist_appointments row and a text log, one is_spam = 1 robocall, one after-hours forwarded call (handled_by forwarded), one weekend voicemail (status voicemail), one Sunday message for the owner (a followup row owned by Dan, due yesterday). Plus twelve knowledge articles, the five skills, four team members, a receptionist_name of Bella. Remove with DELETE … WHERE id LIKE 'demo-rc-%'. Stop the worker before d1 execute --local (executing while it runs corrupts the sqlite file).

Signing in for QA

The v3 project-specsheet.md "Visual QA" section: mint a Clerk sign-in ticket for qa+test@dataripple.com inside the frontend container (the secret never leaves it), exchange it in-page with signIn.create + setActive with the DataRipple Local QA org, then drive Playwright. The prototype's click-through script (50 steps, 1440x900, zero console errors) is the template for the v3 pass; it lives in this session's scratchpad as qa.mjs and the same steps are listed below.

Acceptance checks, Wave 1 (each maps to a prototype step)

Today opens with the status line reading the diagnostics state; blocked turns the line red with the first failing check's fix button.
The brief names the most overdue caller first; with no overdue it says "none past a promise"; singular and plural are right.
The "Needs you" chip count equals the rows beneath it; the live call is in the status line only.
Rows sort overdue by lateness, then due today, then oldest.
A pending receptionist action renders in the default scroll with the full proposed change from args, the recipient, the why, and "If you do nothing, nothing changes."; Meeting Studio actions do not appear.
Approve / Decline / Edit first write through the existing routes; the row moves to Everything she did with an actor and time from the record.
Take it puts my name on an unowned message and it appears in my To Do's under Digital Receptionist and on the Dashboard.
Call back dials and shows "How did it go?"; Reached them closes with my name and the time; Left a message sets due +2h and keeps it open with the note; the attempt is kept on the row.
Hand to requires a person and a time, moves the message to their To Do's, sends a notification (bell drawer), and shows "{Name} owns this, due {time}" to me.
Close it and Reopen are stamped; a request with the agent's worker JWT cannot close a message (403).
Text back sends through the SMS hub and the text appears under What she did with the time; the daily limit error is shown when hit.
Book a visit creates the appointment with sourceCallId; a 409 reads "A visit already exists for this time".
An unanswered question shows on Today and in What she knows; Answer it publishes it; the banner clears.
All calls: search, when and kind filters work; Spam shows is_spam rows; every row opens the drawer; the owner column shows "Nobody yet" for unowned needs.
Every one of the nine old URLs redirects to its new home; the route ledger check passes; every Diagnostics fix button lands on the right Settings section.
All seven Settings sections save through the same endpoints as today; no control from the nine tabs is missing.
The Dashboard Bella card, "Needs you today" and To Do's show the same items as Today; closing on any surface closes on all.
No em dashes; no "Approvals", "Autonomy", "Config", "Diagnostics", "10DLC" or "webhook" as headings anywhere on the receptionist pages.
Zero console errors on every route at 1440x900 with the seed loaded; typecheck and tests green.

Open questions to settle in the first build session

#QuestionDefault if nobody objects
1How does a receptionist pending action link to its call: args, customer_id, or nothing today?Add source_call_id to agent_pending_actions (ALTER migration) and set it from the voice tools; until then link via customer_id.
2The exact /dashboard/sms/* paths for send and thread read, and the daily-budget error shape.Use them as-is; surface the budget error text verbatim.
3Does /dashboard/my-work's approval source already include agent_pending_actions, or only PM approvals?If only PM, add receptionist pending actions under the same source so the "Awaiting your approval" tile counts them.
4Is there a free-slot lookup the voice booking tool uses that can be exposed for the dashboard?Expose it as GET /dashboard/appointments/slots; otherwise Book a visit takes a typed time in Wave 1.
5Business time zone for due times and "after hours": the profile timezone field?Use the contractor profile timezone; fall back to America/Detroit for the demo tenant.
6Vendor detection: contact_type on the call, the CRM company type, or the skill that fired?contact_type when set; otherwise the vendor skill.
7Waiting on your OK: keep as a tab as well as inline on Today, or inline only?Both (prototype).
8Sidebar count badges (Today 5, Waiting on your OK 1): worth adding badge support to dashboard-sidebar-nav.tsx in Wave 1?No; ship without, add in Wave 2 if MJ misses them.
9Can "Ask Rippler about this call" pass a context payload through askRippleOpen() today?If yes, ship in Wave 1; if not, Wave 3.
What this document does not claim. File:line references are from a read of both repos on 2026-09-13 and can drift; check them against the branch you build on. The prototype is a static mock: nothing in it hits an endpoint. The council reviews are role-based model reviews, not customer research.