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).
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
| 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.
Transitions (who, from what, to what)
| 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) |
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 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). |
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 / 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. |
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
);
- Where the extraction runs: next to the only post-call model call that exists today,
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.
- Kept is written by the Done flow: closing a message with outcome reached marks its open promises kept (kept_late when due_at has passed). Never by the agent.
- Drafted answers: when
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".
- Rendered PDFs for estimates and invoices: reuse
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.
- Text it to the caller:
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').
- Repeat caller: add
prior_call_count_30d to the calls list projection (a correlated count on caller_number) or a GET /calls?number= filter.
- Bounce-back and nudge: a scheduled worker reassigns handed messages past due to handed_by, and (optional, gated by
send_sms and the budget) texts the caller a hold-the-line message when a promise passes due with nobody assigned.
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.
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
| # | 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. |
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.