
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.
| Section | September 13 said | September 22 says |
|---|---|---|
| 6.1 Routes and nav | Four 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 Today | Status 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 drawer | A 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 OK | Its 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 Triage | The 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 call | Treated 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 it | Through 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 chrome | Real / 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. |
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.
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.
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.
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.
/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).| Wave | Ships | Backend | Frontend | Done when |
|---|---|---|---|---|
| Wave 1 | Today (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 2 | Extraction 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 3 | Listen 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. |
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).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.
calls.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).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.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.receptionist_appointments row with source_call_id = call.id and status not cancelled. Bella sets it.status = 'completed' with nothing owed: answered from knowledge, handled_by = 'forwarded' and accepted, an appointment moved. Bella sets it.is_spam = 1 (the list hides these unless excludeSpam=0). Bella sets it. Actions: unblock.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
| Source | Due | Shown 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 needs | end 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 answer | now + 2 hours, editable | "Back on your list at 3:15 PM" |
| Hand to a teammate | chosen 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.
| Action | Who | Writes | Result |
|---|---|---|---|
| Take it | viewer with nav.receptionist edit | followup.owner = me (creates the row if none) | needs (mine), shows "You own this" |
| Call back | viewer | followup.owner = me if none; "calling" is client state until an outcome is chosen | dial (tel: link); the message shows "How did it go?" until answered |
| Reached them | viewer | state closed, outcome reached, closed_by, closed_at, note, attempt appended | done; if a promise was overdue the ledger shows "Kept late" (Wave 2). In Triage the next worst folder opens. |
| Left a message / No answer | viewer | state open, due_at = now + 2h (editable), note, attempt appended | needs, "Back on your list at 3:15 PM" |
| Hand to… | viewer | owner, 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 back | viewer | the /dashboard/sms hub send route (confirm path); logs on the thread | state unchanged; the text shows under What she did with the time |
| Book a visit | viewer with nav.scheduling edit | POST /dashboard/appointments with a picked slot and sourceCallId; followup closed with outcome booked | booked; 409 appointment_conflict reads "A visit already exists for this time" |
| Close it (nothing owed) | viewer | state closed, outcome nothing_owed | done |
| Reopen | viewer | state open, closed_* cleared, reopened_by | needs |
| Approve / Decline / Edit first | viewer with edit | existing 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) |
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.
#/home in the prototype; /dashboard/receptionist/index.html in v3)
| Element | Data | Wave | Rules |
|---|---|---|---|
| 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.businessName | 1 | "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 · Settings | counts: needs (derived), today's calls, pending actions (/summary.pendingActions) | 1 | Replaces 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) | 1 | active = 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 overdue | computed 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. | 1 | The 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=pending | 1 | Same 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 it | client preference only: the last view per user (localStorage key rr.todayView in the prototype; a user preference in v3) | 1 | List 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=open | 1 | Default 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 back | client state until an outcome is chosen; then PUT /dashboard/followups/:callId | 1 | Options: 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/count | 1 (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_commitments | 2 | Sorted 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 line | derived; week line from /summary (callsThisWeek, qualifiedLeads24h, voicemailsOpen) plus a booked-this-week count (appointments read, confirm) and promises (Wave 2) | 1 | Collapsed by default. The week line omits the promise fragment until Wave 2. |
?open=<callId>)
| Element | Data | Wave | Rules |
|---|---|---|---|
| Header avatar, name, state pill, promise pill, owner chip, number · city · time · duration, Open in CRM, project link, "2nd call" line | GET /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 2 | 1 | Owner 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 step | 1 | Always the first thing under the header for needs / team / approval. |
| From the project fact | PM: 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) | 2 | Only 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", urgency | 1 | Show 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) | 2 | Hear 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 call | 1 | Same component as 4.4. |
| A question she could not answer card | knowledge unanswered row with source_call_id = this call | 1 | Publishing from here also updates the Today card. |
| Booked line | receptionist_appointments with source_call_id (read route: confirm; the scheduling feature's list endpoint) | 1 | Title, when, who (assignees), where. |
| What she did list | Wave 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. | 1 | Never 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 statement | 2 | Excludes the promise line (it has its own section). |
| Recording and transcript | GET /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) | 1 | Bella'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 stored | 1 (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 route | later | Render 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 bar | by state (section 3) | 1 | Red is used only for the primary action when the message is overdue or urgent, and for Approve. |
#/calls; v3 history.html → calls.html)| Element | Data | Wave | Rules |
|---|---|---|---|
| Search caller, number, what they wanted | client-side over the loaded list (GET /calls supports limit up to 200, offset, status, urgency, afterDate, excludeSpam) | 1 | Debounced, 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 · Spam | derived; Spam requires excludeSpam=0 and is_spam in the projection (add) | 1 | Hung 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 · Open | as 4.1 | 1 | Owner shows "Nobody yet" in red for unowned needs. Every row opens the drawer. Footer: block-list link to Settings → Your number. |
| Export | CSV of the filtered rows | 1 | Client-side. |
#/approvals; v3 action-log.html → ok.html)
| Element | Data | Wave | Rules |
|---|---|---|---|
| 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) | 1 | Generated 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 first | GET /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) | 1 | The 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. | 1 | One stamp per row: actor + time from the record, never a client guess. The rejection reason is shown when present. |
#/setup/<section>)
| Section | Was | Keeps (every control) | Changes |
|---|---|---|---|
| Your number | Phone & 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 rows | Rows 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 when | Routing (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 voice | Config (receptionist-setup.tsx) | greeting, language, answer-after seconds, voice + preview, agent, enabled switch, the six capability switches, business identity read-only rows | Adds "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 knows | Knowledge base (receptionist-faq.tsx + receptionist-faq-article.tsx) | categories, articles, answered / unanswered status, reorder, delete, full-page editor with AI usage rules | Adds 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 calls | Customer 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 asking | Autonomy (receptionist-autonomy.tsx) | the eight gates + SMS budget, review changes, save | Each 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. |
#/rollup)
| Element | Data | Wave | Rules |
|---|---|---|---|
| Dashboard brief pill "need you" and the sentence | GET /dashboard/attention-items (src/routes/dashboard/attention-items.ts) gains receptionist items from open followups (owner = viewer or unassigned for the tenant owner) | 1 | The 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 key | 1 | Each 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 chip | attention-items, merged with the existing PM / CRM items, sorted by lateness | 1 | Same 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. | 1 | Unassigned 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. |
#/triage; index.html?view=triage in v3)

| Element | Data | Wave | Rules |
|---|---|---|---|
| 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 folder | the same lists as Today, sorted worst first | 1 | Pure 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 out | derived states; card count = which cards exist for that call (documents included) | 1 | Click 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 cards | as the drawer | 1 | Changing 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 style | the drawer's own components, reused; bid and project facts are Wave 2 data | 1 | One 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 file | GET /calls/:callId/documents | 1 | Clicking 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 message | as the drawer | 1 | An outcome chosen here (reached them, hand to, close) advances to the next folder after the cards fly out. |

| Element | Data | Wave | Rules |
|---|---|---|---|
| Container a 900px shadcn Dialog above everything (drawer included) | the row's file.downloadPath and file.contentType | 1 | Move 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, close | the row | 1 | Status pill wording from section 7 (Sent, not signed / Signed {date} / Paid {date} / Due {date}, unpaid / Approved by {name}, {date} / Rev {x}). |
| Body | PDF: 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." | 1 | Wave 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. |
ripple-receptionist-worker/src)| Endpoint or path | Used for | Change needed |
|---|---|---|
GET /calls (routes/calls/api.ts:33) | the message list | Projection (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 on | Write 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 player | none (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 line | returns 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 first | the 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 did | reads 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 note | none (PUT accepts the nine receptionist fields in BOOLEAN_FIELDS + sms_budget_per_day) |
POST /dashboard/appointments (routes/dashboard/appointments.ts:69) | Book a visit | body {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 loop | table 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 calls | none |
/contractors/profile, /voices, /voices/preview, /agents, /phone/* | Greeting and voice, Your number | Add 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 week | none (returns members[] with id, name, role, phone via rowToApi) |
notifyUser(env, contractorId, kind, payload, {clerkUserId}) (services/push.ts:315) | the hand-off notification | writes 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-summaries | To Do's, Dashboard | Add 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 file | Bytes 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). |
call_followupsMigration 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 / response | Rules |
|---|---|---|
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 read | requireFeature('nav.receptionist','view'). Resolve "me" to the caller's team member id the way my-work does. |
PUT /dashboard/followups/:callId | partial { 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 body | requireFeature('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 teammate | edit, humanOnly. Wave 2: a scheduled reassign to handed_by when due passes unclosed. |
GET /calls/:callId/documentsNew 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.
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
);
analyzeCallSentiment(...) at durable-objects/CallBridge.ts:1225 (defined services/sentiment.ts:125, model resolved by callLLM(env, 'receptionist.sentiment.analyze', ...) at sentiment.ts:134). Add a sibling extractCallOutcome under a new use-case key (for example receptionist.call.extract, swappable on /admin/models) that returns: a one-line "what they wanted", the list of what she did, the next step, and every sentence Bella spoke that commits the business (promise) or states a fact (statement), each with a due phrase and the knowledge article or rule it came from when the agent used one. Write the first three onto calls (new columns wanted, did_json, next_step) and the rest into call_commitments. Deterministic code parses the due phrase and marks improvised when no source was cited.saveQuestion writes an unanswered row, also draft a body from the nearest published article (same use-case key) into draft_answer. Publishing (PUT /knowledge-base/:id with status answered) sets published_by / published_at. The gate in elevenlabs.ts:478 increments use_count and sets last_used_call_id for each article it loads, so the next message can say "answered from your approved answer, Sep 15".renderContractPdf (services/pm/contract-render.ts: markdown to HTML to PDF through @cloudflare/puppeteer on the BROWSER binding, called at routes/pm/contracts.ts:315 and stored at :346). Add renderQuotePdf / renderInvoicePdf templates that produce the same layout the Estimates and Time & Billing detail pages print today, write to QUOTE_PDFS, record in document_renders, and have 5.2b return file for those rows (GET /calls/:callId/documents/:docId/file streams from the bucket with the same headers as the vault). Render on demand on first open, then cache by content hash. This is a judgment-free transform; no model call.POST /calls/:callId/documents/:docId/share {to} (edit, humanOnly) mints a document_share_tokens row (7 days), sends "{business} sent you {title}: {link}" through the SMS hub within the daily budget, and appends "Texted {title} to {caller}" to the followup attempts so it shows under What she did. GET /share/:token (public, no auth) checks expiry and revocation, streams the bytes with Content-Disposition: inline, increments open_count. Mirror the short-lived Academy media token at routes/dashboard/academy.ts:255 for the pattern. Never for vendor callers (the UI hides the button; the route refuses contact_type = 'vendor').prior_call_count_30d to the calls list projection (a correlated count on caller_number) or a GET /calls?number= filter.send_sms and the budget) texts the caller a hold-the-line message when a promise passes due with nobody assigned.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.
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.
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.
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.| File | Change |
|---|---|
app/routes.ts:527-585 | Inside 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-195 | The 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 hrefs | The 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. |
app/features/receptionist/)| File | Change |
|---|---|
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 calls | Search, when and kind filters, owner column, rows open the drawer. |
receptionist-action-log.tsx → Waiting on your OK | Replaces 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. |
| File | Change |
|---|---|
app/features/productivity/api-client-productivity.ts:26-35, 50-81 | Add "receptionist" to MyWorkSource and myWorkSourceSchema. |
app/features/productivity/productivity-my-work-logic.ts:24-41, 50 | Add 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-54 | Add the receptionist key to dashboardWorkspaceSummariesSchema. |
app/features/dashboard-home/command-cockpit.tsx:72, home-overview.tsx:104-124, home-classic-grid.tsx:32 | The 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. |
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).
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.app/features/dashboard-shell/dashboard-index-redirect.test.ts (the redirectFor() helper) for the nine old paths.// @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.pnpm typecheck then pnpm test serially (repo rule).<object>: fetch with the Bearer token into a blob, and revoke it.app/features/pm/: move what you need to app/lib/.| Where | String |
|---|---|
| Tabs | Today · All calls · Waiting on your OK · Settings |
| Sub-line | {Name} answers {number} for {business}. |
| Head button | Call 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, live | On the line: {caller} · {m:ss} · Listen in · Take over |
| Brief head | Brief 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, clear | Nobody is waiting on you. Nothing I could not answer. Nothing waits on your OK. |
| Messages head | Messages · Worst first. A person closes each one. |
| View switch | List · Triage · Triage works the pile one caller at a time · List shows everything at once |
| Triage head | Triage · 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, clear | Desk is clear. Nobody is waiting on you. The rest of today is here to browse. · Back to the list |
| Triage band | Overdue promise: {caller}, {lateness}. "{promise}" · Open the folder |
| Chips | Needs you {n} · With the team {n} · Everything today {n} · All calls and history |
| Empty pile | Desk is clear. Nobody is waiting on you. |
| Owner chips | You own this · {First name} owns this · Nobody yet · take it |
| Due lines | Owed a call by {time} · {lateness} late · Owed a call this morning · late · Due by {time} · Back on your list at {time} |
| Kinds | Known customer · New caller · Vendor · Unknown number · open project · after hours · 2nd call this month |
| Flags | Please call · Urgent · Wants an estimate · Left a voicemail · Will call again · Returned your call |
| Value | Bella's estimate {range} · Bid {amount} |
| Row buttons | Call back · Text · Hand to… · Close · Approve · Decline · Open · Listen in · Take over |
| Where | String |
|---|---|
| How did it go | You 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) |
| Toasts | Dialling {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 to | Hand {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 sections | The 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 fields | For · Date · time · From · Phone · Address / job site · Message · Taken by · Urgency · Reason · Bella's estimate · Lead · Best time to call · Called before |
| Documents | Documents 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 kinds | Estimate · Quote · Invoice · Contract · Change order · Drawing · Spec book · File · Bill |
| Document status | Sent, not signed · Signed {date} · Paid {date} · Due {date}, unpaid · Approved by {name}, {date} · Rev {x} · Draft |
| Viewer | Open 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. |
| Sources | From: {article}, published {date} · From: the calendar ({calendar}) · From: your {rule} rule · Improvised, not from your answers · Hear it ({m:ss}) · Correct the record |
| Approval card | She wants to {kind}. Your rule: asks first. · Why: {reason} · See the call · If you do nothing, nothing changes. · Approve · Decline · Edit first |
| Stamps | Approved 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 sections | Your 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 |
| Gates | Automatic · Asks first · Daily text limit |
| Knowledge | Published · 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 / Dashboard | Digital 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 |
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).
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.
contact_id set; the message reads "Known customer" and Open in CRM opens the record modal.docs.vault never sees a vault row.| # | Question | Default if nobody objects |
|---|---|---|
| 1 | How 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. |
| 2 | The 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. |
| 3 | Does /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. |
| 4 | Is 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. |
| 5 | Business 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. |
| 6 | Vendor 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. |
| 7 | Waiting on your OK: keep as a tab as well as inline on Today, or inline only? | Both (prototype). |
| 8 | Sidebar 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. |
| 9 | Can "Ask Rippler about this call" pass a context payload through askRippleOpen() today? | If yes, ship in Wave 1; if not, Wave 3. |
| 10 | The 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). |
| 11 | Does 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. |
| 12 | Who 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. |
| 13 | Estimates 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. |
| 14 | Wave 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. |