A new category under Revenue Recovery for the Podium/Chiirp-style notifications: one deterministic text per lifecycle moment, sent from a template rather than a conversation, with the assistant stepping in only if the customer replies. Seven touchpoints, one pipeline, four dashboard screens.
Call the category Touchpoints. It sits as its own tab group between Outreach and Campaigns. Unlike Outreach, a touchpoint is not an outboundLeads sequence: it is a template-rendered message keyed to a CRM event, stored in a new touchpointEvents collection with an idempotent id, dispatched through the already-validated Retell SMS contract with the template as the literal opener. Reply handling is a per-touchpoint mode (assistant / office / acknowledge), and the consent checks that today apply only to recap follow-ups become universal. Nine decisions are queued for you in section 09.
The group label has to read correctly next to "Outreach" and "Campaigns" in the Revenue Recovery nav band, and it has to tell a shop owner that these are moments in a service relationship, not sales pushes.
touchpoint, touchpointEvents, toggle touchpointsEach item is a single point of contact tied to a moment in the job lifecycle: the call dropped, the tech left the shop, the job closed. The word is already how home-services owners talk about Podium ("customer touchpoints"), it carries no promise of a conversation, and it is singular-friendly in the UI ("Edit this touchpoint", "7 touchpoints on").
Glossary entry: touchpoint, a one-shot, template-rendered customer text triggered by a CRM or call event. Contrast with outreach (a multi-step conversational sequence whose goal is a booking) and campaign (a report-driven blast).
Plainest option. Fits en-route, job update and job canceled perfectly, but strains for the review request, the balance reminder and the 6-month check-in, none of which is an "update".
Accurate but reads as legal or billing ("notice of balance due"). Would make the review request feel like a demand.
The existing Outreach machinery is built for a different job. Every campaign today enrolls a prospect in an outboundLeads sequence, hands Retell a generated prompt, and lets the model write the opener. That is right for "we noticed your estimate is still open" and wrong for "Marcus is on the way". The design keeps the two apart on purpose so touchpoints cannot inherit sequence behaviour they should never have (retries across days, model-written openers, stop-on-reply semantics).
outboundLeads + sequenceTemplatestouchpointEvents (id encodes the CRM entity, so a re-fired event is a no-op)create-sms-chat with generated prompt + openeroverride_agent_id contract, literal opener via intro_messageThe backend has no inbound Twilio SMS handler. A customer who answers a Twilio-sent en-route text with "is he still coming?" would be talking to nobody. Retell's SMS chat is the only path in the codebase where a reply lands somewhere (the session while it is open, the /retell-inbound-sms webhook after it closes), and the 2026-09-07 drill in SMS-BASELINE.md proved the exact-opener contract works. So every touchpoint goes out as a Retell SMS chat whose opener is the rendered template, and the reply mode only changes the prompt the agent is handed. The trade-offs are honest: no per-message delivery receipt, a chat session per send, and the fleet-wide shared sender number. Sender identity is decision 3 in section 09 and is shared with the rest of Revenue Recovery, not created by this design.
Ordered by where they fall in the job lifecycle, which is also how the Overview screen groups them. "Data source" is what v1 can actually see: ServiceTitan sends no webhooks, so job and appointment changes come from a five-minute poller; FieldEdge already delivers job.* events to /api/fieldedge/webhook. Abandoned call needs no CRM at all.
| Touchpoint | Fires when | Data source (v1) | Timing | Reply mode | Skipped when |
|---|---|---|---|---|---|
| Abandoned callfollow-up | Postcall marks the call incomplete and no human transfer connected | Archer call record (all CRMs) | 2 min after the call ends | Assistant: picks up what the caller needed, can book | Transfer connected · caller blocked · opted out · legacy dropped-call text is on (see 09.4) |
| Job updatereschedule notice | Appointment start moves by 30 min or more | ServiceTitan poll · FieldEdge job.updated | Immediately, deferred to quiet-hours end | Assistant: offers another time if the new one fails | Change made within 15 min of booking (echo) · appointment already started |
| Technician en routedispatch text | Appointment status becomes Dispatched | ServiceTitan poll · FieldEdge workorder event | Immediately | Acknowledge: thanks them, flags the office if they need something | Event older than 2 h when seen · appointment start already passed · already sent for this appointment |
| Job canceledrebook invitation | Job status becomes Canceled | ServiceTitan poll · FieldEdge job.canceled | 15 min after (lets the office undo) | Assistant: rebooks | Rebooked within the delay · canceled by Archer at the caller's request |
| Review requestGoogle review | Job status becomes Completed | ServiceTitan poll · FieldEdge job.completed | 2 h after completion (configurable) | Acknowledge: a complaint in reply is flagged to the office, never argued | Same customer asked in last 180 d · no review link configured · balance still owed (optional) · excluded job types (warranty, callback) |
| Outstanding balancereminder | Invoice balance above the floor, N days after the job | ServiceTitan daily scan (accounting/v2/invoices) | Day 7, then day 21 (configurable), 9 AM local | Assistant: answers "how do I pay", hands disputes to the office | Balance now zero · below $ floor · reminder cap reached |
| 6-month follow-upcheck-in | Last completed job was 6 months ago, nothing since | ServiceTitan daily scan | 9 AM local | Assistant: books the check-up | Active membership (Maintenance Members owns them) · any job in the window · sent in last 12 months |
Every template is editable per tenant. Variables are a per-touchpoint whitelist; a missing required variable suppresses the send with reason missing_variable rather than sending a blank. The opt-out footer is appended automatically the first time a tenant texts a given recipient.
smsRecipientHealth; sticky until an Archer-team re-enableFour screens, drawn with the Instrument tokens from globals.css and the primitives already in the tree: the two-tier nav band from RevenueRecoveryWorkspace, StatTile, the Quiet Slate SettingsCard / SettingsRow shell, and the shadcn Sheet. Numbers are illustrative.
| When | Customer | Touchpoint | Message | Status |
|---|---|---|---|---|
| 2:31 PM | Riley Nguyen +1 (512) 555-0184 | Technician en route | Comfort Air: Good news, Riley. Marcus is on the way and should arrive between 1:00 – 3:00 PM… | Sent |
| 2:14 PM | Dana Okafor +1 (512) 555-0117 | Abandoned call | Hi Dana, this is Comfort Air. Looks like we got disconnected, sorry about that! Reply here… | Replied · Booked |
| 1:40 PM | Tom Bradley +1 (512) 555-0102 | Review request | Thanks for choosing Comfort Air, Tom! If Priya took good care of you, a quick Google review… | Sent |
| 1:12 PM | Maria Castillo +1 (512) 555-0143 | Job update | Comfort Air: Your appointment has been updated to Thu, Sep 10 at 8:00 AM… | Waiting · quiet hours |
| 11:02 AM | J. Whitfield +1 (737) 555-0166 | Review request | — | Skipped · asked 41 d ago |
| 9:00 AM | Sam Reyes +1 (512) 555-0190 | Outstanding balance | Comfort Air: Friendly reminder that there's a balance of $312.50 on your recent service… | Replied · Paid |
| 9:00 AM | A. Petrov +1 (512) 555-0121 | Outstanding balance | — | Suppressed · opted out |
Not shown: the Dashboard tab gets a small "Touchpoints" strip (sent / replied / booked / collected) under the existing source cards in a follow-up, once the activity data exists to draw it from.
Four ways in, one normalized event, one dispatcher. Everything that could double-text, text at 2 AM, or text someone who said STOP is decided in the dispatcher, which re-reads settings on every run so switching a touchpoint off also cancels what is already queued.
isCallIncomplete and the transfer decisionworkorder. to the accepted prefixes and route job/workorder events herescheduledFor = now + delay, pushed to quiet-hours end; enqueues a Cloud Task on the existing sequence queue patternsourceToggles.touchpoints · this kind enabledsmsRecipientHealth opted out / suppressed · tenant blocked list · disableNumber · testing modecanceledsuppressed: missing_variableoverride_agent_id + dynamic variables; intro_message is the rendered text verbatim; prompt chosen by reply mode/retell-inbound-sms falls back to the latest touchpoint event for that customer when no lead matchestouchpointWrapup mirroring outboundLeadWrapup.notificationNumbers SMS path) with the customer's text quoted. No booking tools are exposed.detectChatOptOut and marks smsRecipientHealth; the event is closed with opted_out.Three new root collections keyed the way the rest of Revenue Recovery keys things (settings by tenant phone, activity by generated id). Nothing new lands on numbers/{phone}, so the settings-field inventory contract is untouched. Types go in packages/types.
export type TouchpointKind = | "abandoned_call" | "job_update" | "tech_en_route" | "job_canceled" | "review_request" | "outstanding_balance" | "service_followup"; export type TouchpointReplyMode = "assistant" | "acknowledge"; // touchpointSettings/{businessPhoneNumber} export type TouchpointSettings = { quietHours: { start: "08:00"; end: "20:00" }; // tenant tz from scheduleSettings.timezone dailyLimit: number; // 200 perCustomerWeeklyCap: number; // 3, en-route exempt optOutFooter: boolean; // true; Archer-team editable reviewUrl?: string; paymentUrl?: string; ownsDroppedCallText: boolean; // suppresses legacy postcall text when true kinds: Record<TouchpointKind, { enabled: boolean; template: string; replyMode: TouchpointReplyMode; delayMinutes: number; options?: Record<string, unknown>; // staleAfterMinutes, cadenceDays, floorCents… }>; updatedAt: string; updatedBy: string; }; // touchpointEvents/{kind}_{businessPhone}_{entityKey}[_{step}] export type TouchpointEvent = { kind: TouchpointKind; businessPhoneNumber: string; crm: "servicetitan" | "fieldedge" | "archer"; customer: { name?: string; phone: string; customerId?: string }; entity: { jobId?: string; appointmentId?: string; invoiceId?: string; callSid?: string }; payload: Record<string, string | number>; // techName, windowStart, balanceCents, oldStart, newStart… status: "scheduled" | "sent" | "replied" | "suppressed" | "canceled" | "failed"; reason?: "quiet_hours" | "opted_out" | "suppressed_recipient" | "cap_reached" | "stale" | "missing_variable" | "kind_disabled" | "admin_disabled" | "frequency" | "provider_error"; renderedMessage?: string; scheduledFor: string; sentAt?: string; retellChatId?: string; outcome?: { booked?: string; rescheduled?: boolean; paid?: boolean; officeNotified?: boolean }; createdAt: string; updatedAt: string; }; // touchpointRecipients/{businessPhone}_{customerPhone} — the frequency ledger export type TouchpointRecipient = { lastSentAt: string; sentKinds: Array<{ kind: TouchpointKind; at: string }>; // trimmed to 30 d reviewRequestedAt?: string; footerSentAt?: string; }; // touchpointCursors/{businessPhone} — ServiceTitan poller state: modifiedOn cursor + last-seen // appointment {status, start} keyed by id, pruned once the appointment is Done or Canceled.
Indexes: touchpointEvents (businessPhoneNumber, createdAt desc), (businessPhoneNumber, kind, createdAt desc), (customer.phone, createdAt desc). The last one is what the inbound-SMS fallback reads.
Three findings from the code map shaped this design more than anything else. Touchpoints text existing customers about their own jobs, so the compliance bar is higher than for a lead follow-up, not lower.
processSequenceStep consults smsRecipientHealth only on the recap_follow_up branch. The touchpoint dispatcher checks it for every kind, fail-closed (a read failure returns 503 so Cloud Tasks retries, exactly as the recap branch does). Lifting the same check into the Outreach path is a separate, recommended PR.{{variables}}. Touchpoints render with plain substitution; the only model call is in the reply session, where it already exists.testingModeConfigs predicate, so a tenant in testing mode has touchpoints logged with suppressed: testing and nothing sent.| Area | New | Changed |
|---|---|---|
| Shared types | packages/types/src/touchpoints.ts | packages/shared/src/notifications.ts: add touchpoint_sms so the abandoned-call send shows in the call-log Notification Status card |
| Backend functions | functions/src/touchpoints/: types, render, guards, dispatch (Cloud Task target), schedule (onCreated), sources/servicetitan-poll, sources/daily-scan, prompts | functions/src/index.ts: register the three functions; outbound-utils.ts: reuse executeOutboundSms with a literal-opener option and the queue helper |
| Backend server | src/call/postcall/steps/touchpointAbandonedCall.ts | droppedCallText.ts: new reason touchpoint_owned; retell-inbound-sms.ts: fallback lookup by touchpoint event; textMessagePrompt.ts: touchpoint prompt builder (no new TextMessageCampaignType member, so the triple declaration is untouched) |
| FieldEdge | packages/fieldedge-core/src/webhook-event.ts: accept workorder.; web webhook handler forwards job/workorder events to the touchpoint ingest | |
| Web | components/Touchpoints/{Overview,EditSheet,Activity,Settings}.tsx, app/api/outbounding/touchpoints/{route,activity/route,test/route}.ts, lib/touchpoints.ts (labels, defaults, variable whitelist) | RevenueRecovery/tabs.ts: new group; revenue-recovery/page.tsx: panels; lib/outbounding-sources.ts: touchpoints control source; Admin/OutboundingControls: the switch |
| Firestore | three composite indexes | |
| Docs | architecture-call-flows-and-functions.md (new section + diagram), glossary.md (touchpoint; outreach vs campaign vs touchpoint), ./scripts/check-glossary.sh run | |
| Tests | render, guards (each reason with a control), poller diff, id idempotency, inbound fallback, abandoned-call ownership; all under the offline SMS config so no transport is possible |
Each has a recommendation so a single "go with your recs" is a complete answer. Anything you change, I'll fold into the build.
RETELL_SMS_PHONE_NUMBER, same as every Outreach text. Customers expecting the shop's own number is a Revenue Recovery-wide question (A2P registration per tenant). Ship on the shared sender and show it read-only in settings?Four PRs, each shippable and each dark until the Archer-team switch is on. Phase 1 puts the whole UI and the dispatcher in front of a pilot with the one touchpoint that needs no CRM.