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 documents read, 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". This is the second edition: it folds in the Triage view and the documents decision and corrects two assumptions the first edition made (section 1).

What the September 22 refresh changes in this plan

The prototype was put through MJ's September 22 standards (see the hub's "Refreshed on September 22" table). The backend plan below stands: the one new table, its three routes, the projections, the documents read and the Wave 2 extraction are unchanged. The frontend shape moved. Read sections 4 and 6 with these deltas applied; where a line further down contradicts a line here, this box wins.

SectionSeptember 13 saidSeptember 22 says
6.1 Routes and navFour tabs: Today, All calls, Waiting on your OK, Settings; ok.html; count badges on sidebar sub-items optional.Three tabs: Today, All calls, Settings. No ok.html: action-log.html redirects to Today. The Today sub-item carries the needs-you plus your-OK count if sidebar badges are cheap; otherwise none.
4.1 TodayStatus line, BLUF band, Waiting on your OK inline, the slip pile with Call back / Text / Hand to / Close, the promise ledger table, the questions section, Handled without you, the week line.One heads-up line only while a diagnostics check fails (from GET /phone/diagnostics); four chips (Needs you, Promised, With the team, Everything today); the day in five columns by time block (still open from before, before 9, 9 to 11, 11 to 1, after 1), one small card per call with a pill and, only where there is a choice, one red button and one plain word. No brief, no status line, no ledger table, no questions section, no weekly line.
4.2 The message drawerA 760 pixel Sheet with nine sections and an action row at the bottom.A page (#/calls/:id, call.html in v3): the library on the left; the action bar at the top with "Change who handles it" and "See the exact change" folding under it; What happened beside What is owed; the recording; contact and documents; history. Sheets are for the records behind names and counts: contact, teammate, promise, question, approval, article, instruction, the broken check.
4.4 Waiting on your OKIts own tab plus inline on Today; the activity feed under it.Folded: a Your OK pill on the card with Approve or Skip; the action bar on the call with Approve, Edit (PATCH editable_payload) and Skip. The source-filtered activity feed moves under Settings, What she can do without asking, as "What she did on her own".
4.7 TriageThe second view of Today; receptionist-triage.tsx.Cut. The library plus the record page is one caller at a time. Do not build receptionist-triage.tsx unless MJ brings it back.
Approvals, bookings and texts from a callTreated as Wave 1 on the existing pending-actions, appointments and sms paths.Audited September 22: the phone agent's tool catalog (services/mcp/voice-catalog.ts) has no booking, texting, tagging or contact-change tool; the capability switches only add "Do NOT ..." prompt lines when off. Three voice tools are new backend work before those flows exist: send-text behind the send_sms gate (writing sms_messages), booking with a free-slot read (writing receptionist_appointments with source_call_id), and a propose-change tool that queues update_contact with phone fields and the call id.
Text back, Text itThrough the /dashboard/sms/* hub, paths to confirm.Confirmed absent on origin/main (September 20): only the Twilio inbound and status webhooks exist under /sms. Neither button is drawn. A dashboard send route is a separate backend item if wanted; Email her uses POST /dashboard/contacts/:id/send-email.
7 Copy deck"Decline", "Nobody yet · take it", "Hand to…", "Nothing owed, close it"."Skip", "Nobody yet, take it", "Change who handles it", "Close". No page subtitles, no explanatory sentence above any list, no "coming soon".
Build notes chromeReal / sample / where-the-tabs-went labels behind a toggle.Gone from the product. The prototype's Live today switch tags pieces live, needs the build, or gated with the route on hover; it is review chrome only.
1. What we are buildingdecisions, the six rules, what research changed 2. Waves and donewhat ships when, acceptance per wave 3. The message state machinestates, derivation, transitions, due times 4. Screens, element by elementToday, Triage, drawer, documents, All calls, OK, Settings, rollups 5. Backend: data and endpointsMatt: what exists, one table, the documents read 6. Frontend: file planSeth: routes, nav, pages, viewer, 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); her open messages roll up into To Do's and the RC Dashboard; Today has two views of the same messages, List (default) and Triage (the pile, one caller at a time), and the Triage cards and the message drawer carry the caller's real documents (estimates, invoices, contracts, drawings, vendor bills) whenever the business already holds them. 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. Triage opens on that same first caller.
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.
6
Documents come to the caller; nothing is copied into the receptionist."Documents on file" is a read over the places documents already live (Estimates, Time & Billing, Contracts, the project's vault and drawings, Finance vendor bills), joined through the caller's contact and projects. The receptionist stores no files and no new document rows; deleting a document at its source removes it here. Rows appear only when something exists; a caller with nothing on file has no card.

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 and one new read. The promise ledger and "said on the record" sources wait for their backend (Wave 2) and are not rendered until then; no placeholder rows. A document row without a stored file opens its record page, it never pretends to be a PDF.

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), including the five callers with documents.

Five 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); Text back uses that. Note that the text_chat_threads / text_message_logs tables described in the worker's .claude/CLAUDE.md are not in the live consolidated schema (the base sms_messages table at 001-consolidated-schema.sql:160-171 has no media column), so confirm the hub's tables with Matt before the Text back thread view. (2) "Questions she could not answer" is already half built: 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. (4) calls.contact_id exists but nothing writes it. The column (001-consolidated-schema.sql:46) and its index (:5896) are there, and work-order-intake.ts:99-101 already reads it, but every INSERT INTO calls omits it (src/routes/calls/incoming.ts:124-333, onboarding.ts:43) and no UPDATE sets it; the origin migration (src/db/archive/057-calls-contact-id.sql:11-13) says it stays NULL until a phone match or a backfill exists. Neither does. Known customer, Open in CRM, "2nd call" and every document row rest on this link, so the phone-match writer is a Wave 1 backend item (5.1), with a one-time backfill. (5) There is no CRM document store. No contact-scoped documents table exists (searched: contact_documents, crm_documents, company_documents, crm_files, a bare documents / files / uploads / media; none), people has no file relation, and the generic attachments table's CHECK only allows project, task, note (001-consolidated-schema.sql:730-742; the note entity is project_notes). Documents that do reach a caller live in five places, and "Documents on file" is one union read over them (5.2b): quotes.contact_id (Estimates; no PDF anywhere), invoices.contact_id (Time & Billing; no PDF by design, time-billing.ts:3469), contract_summaries (a real PDF in the DOCUMENTS bucket, reached through quotes.contract_summary_id or the project), the project's Document Vault, drawing sheets and spec books (real files, reached through projects.customer_contact_id / primary_person_id / company_id / crm_source_call_id), and vendor bills pm_invoices + pm_invoice_documents (a real PDF in FILES, through vendor_contact_id; confirm that column references people.id).
2 · Waves, and what "done" means for each

Wave 1 is a frontend regroup plus one table, one writer and one read. Wave 2 is the extraction step, the two loops and rendered PDFs. 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) with its Triage view (the same Today as folders, one caller at a time). Message drawer (message, what to do, recording, transcript, details, documents on file, call back / text back / book / hand to / close, how-did-it-go). Documents on file (the union read; rows for every document the business holds on the caller; the viewer for the ones that have a stored file: contracts, vault files, drawing sheets, spec books, vendor bills; rows without a file open their record). 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. The calls.contact_id writer (phone match at call create) + backfill. GET /calls/:callId/documents (the union read, no new table). 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 (List + Triage) + drawer + documents card + viewer (moved to app/lib/) + 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 seeded contract, drawing and vendor bill open inline from the right callers; the seeded estimate and invoice rows open their records; a caller with nothing on file shows no card. 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. Rendered PDFs for estimates and invoices so they open inline like contracts. Text it to {caller} from the viewer.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. A document_renders table fed by the contract renderer that already exists (renderContractPdf, headless Chrome) for quotes and invoices, stored in the bound-but-unused QUOTE_PDFS bucket. A short-lived share token for texting a document.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. Estimate and invoice rows gain the inline viewer; "Text it to {first name}" appears in the viewer footer.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). Karen's estimate opens as a PDF and "Text it to Karen" delivers a link that opens the same PDF and expires.
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 for the regroup (the feature is 18 files, 7,535 lines; the Settings regroup is mostly moving and relabelling), plus two for the Triage view and two for the documents card and the shared viewer. Wave 1 backend two to three days (table, three routes, one filter, three projection columns, my-work and dashboard feeds), plus one for the contact writer and backfill and one to two for the documents read. Wave 2 about a week of backend (the extraction prompt, a commitments table, the overdue check, four knowledge columns) plus two days of frontend, plus two days for rendered PDFs and one for share links. Wave 3 is provider-dependent. Sizing assumes the local QA recipe in the v3 project-specsheet.md ("Visual QA") is used from day one, and that Seth builds from a fresh worktree off origin/main (the OneDrive checkout is on feat/sidebar-console-launchers, behind main; FE PR #302 and app/lib/crm-record-url.ts exist only on main).
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 (both views), 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 or in Triage. 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 (needs the Wave 1 writer, 5.1) ; "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). In Triage the next worst folder opens.
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-46 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 ending in "Start with Karen", the approval waiting, the List | Triage switch, 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. Triage is not a tab: it is a view of Today.
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. Present in both views.
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 paragraph ending in "Start with {first name} →" when someone is overduecomputed 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. "Start with {name}" opens Triage at that caller and remembers the view.
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).
View switch List | Triage in the Messages header, hint text beside itclient preference only: the last view per user (localStorage key rr.todayView in the prototype; a user preference in v3)1List is the default. Triage renders 4.7 inside the same route with the same data, page head, status line and tabs: one screen with two views, not a fifth tab. Landing on Today with "triage" remembered opens Triage; the List switch resets it. "Start with {name}" and the Dashboard card deep-link straight into Triage at the worst caller.
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, details, documents on file.
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 once the writer lands); CRM link = crmRecordHref("person", contactId) from app/lib/crm-record-url.ts:34-53 (origin/main), which resolves to /dashboard/crm/people.html?record=person%3A{id} and opens the global record modal; project link and prior calls are Wave 21Owner chip is a button when unassigned ("Nobody yet · take it"). Do not hand-build the old /dashboard/crm/people/detail.html?id= href (the branch copy at receptionist-call-detail.tsx:349 still does; main uses the helper at :351).
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); the project comes from projects.crm_source_call_id = call.id or the caller's contact (5.2b)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, proxies Twilio with Range passthrough, private, no-store); 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.
Documents on file (rows: PDF badge, title, kind + number, pages or size, date, by; amount; status; source chip)GET /calls/:callId/documents (5.2b): the union of the caller's estimates (quotes), invoices (invoices), contracts (contract_summaries), project vault files (project_files), drawing sheets, spec books, and vendor bills (pm_invoices), each with file null when nothing is stored1 (estimate and invoice PDFs, Text it: 2)Rendered only when the read returns at least one row (no empty state, no card). Newest first, at most 8, then "All {n} documents" linking to the source page. A row with a file opens the viewer (4.8); a row without one opens its record in the source app (Open in Estimates / Open in Time & Billing / Open the project / Open in Finance) in the same tab. The badge reads PDF, IMG or FILE from the content type. Amount from total_cents / amount_cents formatted as currency; status from the source row's status (section 7 has the labels). Never show a row the viewer cannot open (the read already filters by feature gate).
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.html → calls.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.html → ok.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 big number opens Triage (index.html?view=triage). 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 Triage, the second view of Today (#/triage; index.html?view=triage in v3)

Karen's folder open on the desk: the same head and status line as the list, folder tabs on the left, the navy folder tab with the badge, the cards as header bars with the message open.
The Documents card in the same folder: the estimate and an old invoice, each with amount, status and where it lives. The bid card's link now opens the estimate's PDF.
ElementDataWaveRules
Entry the List | Triage switch on Today, "Start with {name}" in the brief, the Dashboard card's big number; the band at the top names the worst overdue promise and opens that folderthe same lists as Today, sorted worst first1Pure frontend, a view mode of the Today route: no endpoint the list view does not already call, plus the documents read per open folder. After an action closes the open folder (Reached them, hand to, close, approve) the list re-sorts and the next worst opens at the same position; deal its cards in again. When nothing needs you: "Desk is clear. Nobody is waiting on you. The rest of today is here to browse." with "Back to the list". Build it after the list view works.
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 call (documents included)1Click 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 documents read for the folder runs when it opens; the chip reads "Documents (n)" once it returns and is absent when n = 0.
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, documents on file, 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 are simply absent.
Documents card header "Documents on file · {n} from {sources}", the same rows as 4.2; the open-bid card's link reads "Open the bid PDF" when the estimate has a fileGET /calls/:callId/documents1Clicking a row opens the viewer (4.8) above the desk; Escape returns to the folder with the card still open. The folder's arrow keys are suspended while the viewer is up. The card never shows placeholders: a folder with nothing on file has no Documents chip.
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.

4.8 The document viewer (opens from the drawer and from the Triage card)

Karen's estimate opened: header with the badge, title, meta and where it lives; the document itself; page controls, Open in Estimates, Download, and (Wave 2) Text it to Karen.
ElementDataWaveRules
Container a 900px shadcn Dialog above everything (drawer included)the row's file.downloadPath and file.contentType1Move PM's DocumentViewer (pm-documents.tsx:81) to app/lib/document-viewer.tsx and reuse it: authed fetch of the bytes with the Bearer token, URL.createObjectURL, revoke on close. There are no public or signed R2 URLs anywhere in the worker (document-vault.ts:39, attachments.ts:22-24), and every download route sets private, no-store, so the viewer must fetch, never link.
Header PDF / IMG / FILE badge, title, "{kind} {number} · {pages or size} · {date} · {by} · {source chip}", status pill, closethe row1Status pill wording from section 7 (Sent, not signed / Signed {date} / Paid {date} / Due {date}, unpaid / Approved by {name}, {date} / Rev {x}).
BodyPDF: an <iframe> over the object URL (the browser's own PDF viewer, as PM does today); images: <img>; anything else: "Preview is unavailable for this file type. Use Download."1Wave 2 option: render pages to canvas with pdfjs-dist the way pm-spec-viewer.tsx:25-37 does (dynamic import inside the function only, never at module top level; pm-drawings-pdf.ts:7-9 on origin/main states the rule), which gives the prototype's "Page 1 of 3" controls and thumbnails (renderPdfPage with THUMB_SCALE, pm-drawings-pdf.ts:17, 194). In Wave 1 the page controls are the browser's; omit the prototype's footer pager.
Footer Open in {source} · Download · Text it to {first name}Open = the row's href (Estimates detail, Time & Billing invoice detail, /dashboard/pm/documents.html?id={projectId}, /dashboard/pm/drawings/index.html?id={projectId}&tab=current, the Finance AP batch page); Download = the blob to <a download> with the filename from the row (the worker sets Content-Disposition: attachment; parse it with the helper at api-client-work-orders.ts:372 or use file.fileName)1 (Text it: 2)"Text it" is hidden until Wave 2 and never shown for vendor callers. Wave 2: POST /calls/:callId/documents/:docId/share mints a 7-day token and sends the link through the SMS hub; GET /share/:token streams the bytes and logs the open under What she did.
5 · Backend: data and endpoints Matt

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

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

Endpoint or pathUsed 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.
INSERT INTO calls (routes/calls/incoming.ts:124, 145, 171, 281, 316, 333; onboarding.ts:43)the contact link everything else hangs onWrite contact_id at call create: normalize caller_number to E.164 and match people.phone_number (and the mobile field, confirm the column names) within the tenant; on several matches prefer the person with the most recent activity; leave NULL when none. Also set contact_type from the match (customer / vendor, from the company or tag; confirm). Add a one-time backfill script for existing rows (same rule). The index idx_calls_contact (001-consolidated-schema.sql:5896) and the reader in services/operations/work-order-intake.ts:99-101 already expect it.
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 (SELECT * already returns contact_id; the recording proxies Twilio with Basic auth and Range passthrough)
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, per the worker's .claude/CLAUDE.md)Text back; the text thread on the message; Wave 2 "Text it"Confirm the exact send and thread paths and tables. 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; the live schema's sms_messages (001-consolidated-schema.sql:160-171) has no media column.
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.
Document sources (read by 5.2b, never called by the frontend directly except for the file bytes): Estimates quotes (001-consolidated-schema.sql:3739; contact_id, project_id, number, type, status, title, total_cents, signed_at, contract_summary_id, invoice_id; index idx_quotes_contact :6941); Time & Billing invoices (:2884; contact_id, company_id, project_id, number, status, issue_date, due_date, total_cents, paid_cents; no contact index); contract_summaries (:2960; project_id, quote_id, r2_key, page_count, file_size_bytes, source UPLOADED / GENERATED, execution_date); project_files + project_folders (:1925 / :1913; the vault, tenant only via the folder); drawing_sheets (:1726; r2_key, sheet number, title, revision); spec_books (:1850; r2_key, page_count); pm_invoices + pm_invoice_documents (:3551; 20260719230716-w3-5-pm-vendor-invoice-intake.sql:43; vendor_contact_id, r2_key, pages, content_type); pm_change_orders (:3490; no file)Documents on fileBytes are served by the routes that already exist and already stream through the worker with private, no-store: GET /api/pm/contracts/:id/source (routes/pm/contracts.ts:444, DOCUMENTS bucket), GET /api/pm/documents/files/:id/download (routes/pm/document-vault.ts:540, gate docs.vault), the drawing sheet image (routes/dashboard/drawings.ts:378), GET /dashboard/specs/spec-books/:bookId/pdf (specs.ts:239), the vendor invoice document (routes/finance-ap.ts:18-24, FILES bucket). No PDF exists for quotes or invoices (no route, no library; QUOTE_PDFS is bound in wrangler.jsonc:97-98 but unused; time-billing.ts:3469 says invoices ship without one); their rows carry file: null in Wave 1. No document table carries a call id; the only call links are calls.contact_id and projects.crm_source_call_id (:1145).

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.

5.2b The documents read (Wave 1, no new table): GET /calls/:callId/documents

New file src/routes/calls/documents.ts, mounted on the calls router next to the recording route, gate requireFeature('nav.receptionist','view'). One read resolves the caller, then unions what the business holds on them. It stores nothing; it returns rows with an href into the source app and, when a stored file exists, a file the viewer fetches through the existing download route. Rows whose download route the viewer cannot pass are omitted (check docs.vault, execution.tasks, the drawings and finance gates with the same requireFeature matrix, routes/account-roles.ts:220), so the card never shows a document that 403s on click.

-- 1. who called (all within the tenant)
person   = people WHERE id = call.contact_id                       -- after the Wave 1 writer; fallback: phone match on caller_number
company  = companies WHERE id = person.company_id
projects = projects WHERE crm_source_call_id = call.id
        UNION projects WHERE customer_contact_id = person.id OR primary_person_id = person.id
        UNION projects WHERE company_id = company.id                -- only when person.company_id is set

-- 2. what the business holds on them (newest first, deleted_at IS NULL everywhere)
quotes             WHERE contact_id = person.id                     -> kind estimate|quote, file: contract_summaries via contract_summary_id (else null)
invoices           WHERE contact_id = person.id OR company_id = company.id  -> kind invoice, file: null (Wave 2 render)
contract_summaries WHERE project_id IN projects OR quote_id IN (the quotes above)  -> kind contract, file: /api/pm/contracts/:id/source
project_files JOIN project_folders f ON f.id = folder_id WHERE f.project_id IN projects AND f.contractor_id = :tenant
                                                                    -> kind file, file: /api/pm/documents/files/:id/download
drawing_sheets     WHERE project_id IN projects AND is_active = 1   -> kind drawing, file: the sheet image route (drawings.ts:378)
spec_books         WHERE project_id IN projects                     -> kind spec, file: /dashboard/specs/spec-books/:id/pdf
pm_invoices        WHERE vendor_contact_id = person.id              -> kind bill, file: the vendor invoice document (finance-ap)  (confirm the FK target)
pm_change_orders   WHERE project_id IN projects                     -> kind change_order, file: null (no document exists)

-- 3. the shape (amounts in cents; dates ISO; at most 40 rows, the client shows 8)
{ "callId": "...", "contactId": "...", "projectIds": ["..."],
  "documents": [{
    "id": "quote:q_123", "kind": "estimate", "title": "Kitchen remodel", "number": "EST-2041",
    "status": "sent", "amountCents": 1850000, "date": "2026-09-09", "by": "Dan Kowalski",
    "source": "estimates",                                  -- estimates | time_billing | contracts | project | drawings | specs | finance_ap
    "href": "/dashboard/estimates/detail.html?id=q_123",    -- the record page, always present
    "file": null | { "downloadPath": "/api/pm/contracts/cs_9/source", "contentType": "application/pdf",
                     "fileName": "Bath remodel contract.pdf", "sizeBytes": 419840, "pages": 6 }
  }] }

Notes. project_files has no tenant column: always join through project_folders.contractor_id as document-vault.ts:545-550 does. pages exists only on pm_invoices.pages and contract_summaries.page_count / spec_books.page_count; leave it null elsewhere and the client shows the size. The estimates list route has no contactId filter (routes/pm/estimates/index.ts:104-129) and the invoices list has none either (time-billing.ts:733), which is why this read queries D1 directly instead of calling those routes. Cache nothing: the read is cheap (indexed by contact and project) and must reflect a deletion immediately.

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, rendered PDFs, share links

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
);

CREATE TABLE IF NOT EXISTS document_renders (
  id TEXT PRIMARY KEY, contractor_id TEXT NOT NULL REFERENCES contractors(id),
  entity TEXT NOT NULL CHECK (entity IN ('quote','invoice')), entity_id TEXT NOT NULL,
  content_hash TEXT NOT NULL,               -- of the rendered input; re-render only when it changes
  r2_key TEXT NOT NULL,                     -- QUOTE_PDFS bucket (bound, unused today): {contractorId}/{entity}/{entity_id}/{hash}.pdf
  page_count INTEGER, byte_size INTEGER, rendered_at TEXT NOT NULL,
  UNIQUE (contractor_id, entity, entity_id, content_hash)
);

CREATE TABLE IF NOT EXISTS document_share_tokens (
  token TEXT PRIMARY KEY,                   -- 32 random bytes, base64url; the whole secret is the URL
  contractor_id TEXT NOT NULL REFERENCES contractors(id), call_id TEXT NOT NULL REFERENCES calls(id),
  document_ref TEXT NOT NULL,               -- the 5.2b row id, e.g. contract:cs_9
  sent_to TEXT NOT NULL,                    -- E.164
  created_by_user_id TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL,
  opened_at TEXT, open_count INTEGER NOT NULL DEFAULT 0, revoked_at TEXT
);

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). The documents read joins, it never copies: revoking a source row hides the document everywhere at once, the same argument attachments.ts:22-24 makes for streaming instead of signed URLs.

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. Documents: the read filters rows by the source gate (docs.vault for vault files and contracts, the drawings and specs gates, the finance AP gate); the file bytes still come from the source route with its own gate. Share: nav.receptionist edit, humanOnly. 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. Quotes: the Estimates status set plus signed_at; T&B estimates: draft, sent, accepted, declined, invoiced; invoices: the Time & Billing status set with paid_cents; contracts: UPLOADED / GENERATED with execution_date.

6 · Frontend: file plan Seth

Regroup the existing pages, add six files, move one, keep every endpoint call

Build from a fresh worktree off origin/main. The OneDrive checkout is on feat/sidebar-console-launchers, behind main: FE PR #302 (PDF drawing sets, pm-drawings-pdf.ts) and app/lib/crm-record-url.ts exist only on main, and receptionist-call-detail.tsx differs between the two (the CRM href at :349 vs :351). Cross-feature imports are forbidden by the repo's CLAUDE.md: anything the receptionist reuses from PM moves to app/lib/ first, and PM imports it from there in the same PR.

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, with ?view=triage and ?open=), 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, ViewSwitch.
receptionist-logic.ts (1124 lines)Add deriveMessageState, messageDue, isOverdue, sortWorstFirst, briefSentence, nextStepTemplate, slipFlags, isHungUp, callerKind, proposedChangeRows(action) (the args template map), documentBadge(contentType), documentStatusLabel(row), documentSourceLabel(source), 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), fetchCallDocuments(callId) (5.2b, zod schema for the shape), fetchDocumentBlob(downloadPath) (Bearer fetch returning { blob, contentType, fileName }, reusing the recording fetch at :110-118 as the pattern), Wave 2 shareDocument; 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 and ?view=triage or the stored preference to render ReceptionistTriage in place of the message list.
receptionist-message-drawer.tsx (new)The drawer in 4.2 using the shadcn Sheet (app/components/ui/sheet.tsx; the canonical right drawer is crm-people-notes-drawer.tsx:133-237); reuses the recording object-URL logic from receptionist-call-detail.tsx:102-128 (revoke on close); renders DocumentsOnFile after the details. call.html stays as the full-page fallback and keeps Delete call (canDeleteCall) and End call.
receptionist-documents.tsx (new)useCallDocuments(callId) (fetch on open, cache per call id for the session, refetch after Text it) and DocumentsOnFile: the rows in 4.2, the "All {n} documents" link, and the click handling (file → DocumentViewer; no file → navigate to href). Used by the drawer and by the Triage card. No PM imports.
app/lib/document-viewer.tsx (moved from pm-documents.tsx:81)The existing DocumentViewer (Dialog, authed blob, iframe for PDF, img for images, text for text types, fallback message, object URL revoked on close) moved to app/lib/ with a fetchBlob prop so each feature passes its own Bearer fetch; PM switches to the import. Add the footer slot for Open in {source} / Download / Text it. The Wave 2 pdfjs canvas mode lives here too, following the dynamic-import rule from pm-spec-viewer.tsx:25.
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-triage.tsx (new)The Triage view (4.7): folder rows, the open folder, the card accordion (message, transcript, promise, contact, documents, project, bid, appointment, question, approval), the worst-promise band, the desk bar with the same action buttons as the drawer. Rendered by receptionist-index.tsx when the view is triage; keyboard Left / Right (folders), Up / Down (cards), Enter (drawer), all suspended while a Dialog or Sheet is open. CSS transitions only (translate, opacity, max-height); no new data beyond the documents read per open folder.
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. The big number links to index.html?view=triage.

Grammar to reuse

SettingsCard, PageMenu (app/components/ui/page-menu.tsx), shadcn Badge / Button / Dialog / Switch / Sheet / Card, Sonner toasts, useConfirmDialog() (confirm-dialog.tsx:116; native confirm was retired). The RFI AttachmentsCard (pm-rfi-detail.tsx:208) is the closest existing row grammar for documents; the PM vault table (pm-documents.tsx:74) has the byte and date formatters (:84-85). 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; documentStatusLabel and documentBadge per kind.
  • 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, the documents section absent when the read returns none, a row without a file navigates instead of opening the viewer, Escape closes the viewer and restores the Triage keys. 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, no placeholder documents).
  • Let the agent's worker JWT close a message (all followup writes are humanOnly).
  • Link to a worker file URL, open one in a new tab, or embed one with <object>: fetch with the Bearer token into a blob, and revoke it.
  • Import from app/features/pm/: move what you need to app/lib/.
  • 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. Start with {first name} →
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.
View switchList · Triage · Triage works the pile one caller at a time · List shows everything at once
Triage headTriage · One caller at a time. Each person is a folder; hover a header to peek, click to open it, click the open card for the full record. Left and Right move folders, Up and Down cards.
Triage, clearDesk is clear. Nobody is waiting on you. The rest of today is here to browse. · Back to the list
Triage bandOverdue promise: {caller}, {lateness}. "{promise}" · Open the folder
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. · Texting {first name} a secure link to {title} at {number} (expires in 7 days). Logged under What she did.
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 · Documents on file
Drawer fieldsFor · Date · time · From · Phone · Address / job site · Message · Taken by · Urgency · Reason · Bella's estimate · Lead · Best time to call · Called before
DocumentsDocuments on file · {n} from {Estimates, Time & Billing, Project, Finance} · PDF · IMG · FILE · {kind} {number} · {n} pages · {size} · {date} · {by} · All {n} documents · Open the bid PDF
Document kindsEstimate · Quote · Invoice · Contract · Change order · Drawing · Spec book · File · Bill
Document statusSent, not signed · Signed {date} · Paid {date} · Due {date}, unpaid · Approved by {name}, {date} · Rev {x} · Draft
ViewerOpen in Estimates · Open in Time & Billing · Open the project · Open in Finance · Download · Text it to {first name} · Page {n} of {m} · Preview is unavailable for this file type. Use Download.
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} · Texted {title} to {caller}, {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, and people rows for the six known callers with calls.contact_id set. Documents: Karen: a quotes row (type estimate, status sent, 1850000 cents, three line items) and a paid invoices row from March; Greg: a project with crm_source_call_id = his Sep 9 call, a contract_summaries row (source UPLOADED, a real 6-page PDF in the local DOCUMENTS bucket), a pm_change_orders row, an active drawing_sheets row with a sheet image; Ferguson: a pm_invoices + pm_invoice_documents pair with a 2-page PDF and vendor_contact_id = Ferguson's person; Sam: a signed quotes row (type quote) with a GENERATED contract; Tom: a paid invoices row from June 2024. Priya, Amanda, Chris, Linda and the unknown numbers get nothing, on purpose. 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 (52 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 and ends with "Start with {name}"; with no overdue it says "none past a promise" and has no start link; 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.
The List | Triage switch keeps the page head, tabs and status line; Triage opens on the worst caller; closing that caller opens the next worst; the choice is remembered on the next visit and the List switch resets it.
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.
A new inbound call from a seeded person's number gets contact_id set; the message reads "Known customer" and Open in CRM opens the record modal.
Documents on file: Karen shows the estimate and the March invoice (rows open Estimates and Time & Billing); Greg shows the contract, the change order and the drawing, and the contract and drawing open inline in the viewer; Ferguson shows the bill and it opens inline; Priya has no documents section and no Documents chip in Triage.
The viewer fetches with the Bearer token (no worker URL in the DOM), Download saves the file under its name, Escape closes it and returns to the folder with the card still open; a viewer without docs.vault never sees a vault row.
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; the card's big number lands in Triage.
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 and tables for send and thread read, and the daily-budget error shape (the CLAUDE.md description does not match the live schema).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 (written by the new contact match); 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.
10The phone-match rule for calls.contact_id: which people columns hold phones, how to break ties (two people, one number), and whether to match companies too.E.164 on every phone column of people; tie-break by most recent activity; company match only when no person matches (then contact_id stays NULL and the company goes on company_name).
11Does pm_invoices.vendor_contact_id reference people.id, or a vendors table?If people, vendor bills join directly; if vendors, join through vendors.contact_id (confirm) or leave bills out of Wave 1.
12Who may open documents from the receptionist: the source gates as they stand (docs.vault and friends), or a single nav.receptionist read that trusts the receptionist page?The source gates; the read filters rows the viewer cannot open so nothing dead is shown.
13Estimates detail route for href (the prototype assumes /dashboard/estimates/detail.html?id=), and the Finance AP page to open for a vendor bill.Confirm both in the route ledger; fall back to the list pages with the record highlighted.
14Wave 2 render templates: reuse the contract markdown template mechanism (project_contract_templates) for quotes and invoices, or hard-code the two layouts?Hard-code two layouts that match the detail pages; templates later if customers ask.
What this document does not claim. File:line references are from a read of both repos on 2026-09-13 (the documents research read the worker's consolidated schema and route files and the v3 PM, CRM, receptionist and Time & Billing features) and can drift; check them against the branch you build on. The prototype is a static mock: nothing in it hits an endpoint, and its document viewer draws sample pages in HTML where the real one shows the stored file. The council reviews are role-based model reviews, not customer research.