Design proposal · 2026-09-07 · Revenue Recovery

Touchpoints: service texts that keep customers close

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.

statusawaiting review scopedesign + mockups, no code yet branchben/revenue-recovery-outreach-c37f89
TL;DR

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.

01

The name


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.

Touchpoints
recommended · code: touchpoint, touchpointEvents, toggle touchpoints

Each 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).

Service Updates
alternate

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".

Customer Notices
alternate

Accurate but reads as legal or billing ("notice of balance due"). Would make the review request feel like a demand.

02

What a touchpoint is, and what it is not


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

Outreach (today)
Touchpoint (proposed)
Goal
Book a job or sell an estimate
Inform, remind, or ask for one thing; a booking is a welcome side effect
Who writes the first text
The model, from a per-campaign prompt
The template, rendered deterministically; no model call before send
Shape
Multi-step sequence (SMS, call, SMS…) over days
One message per event; balance reminders may repeat on a fixed cadence
Trigger
Daily scan or lead ingestion
A CRM state change (dispatched, canceled, completed, rescheduled), a postcall signal, or a dated scan
If the customer replies
Sequence stops; Retell agent continues the conversation
Per-touchpoint reply mode: assistant books/reschedules, or acknowledges and flags the office
Storage
outboundLeads + sequenceTemplates
touchpointEvents (id encodes the CRM entity, so a re-fired event is a no-op)
Transport
Retell create-sms-chat with generated prompt + opener
Same endpoint, same validated override_agent_id contract, literal opener via intro_message
Consent window
Enforced for 2 of 10 campaign types
Quiet hours, opt-out, and frequency cap enforced for every touchpoint, in one guard

Why one transport, and why Retell rather than Twilio

The 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.

03

The seven touchpoints


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.

TouchpointFires whenData source (v1)TimingReply modeSkipped when
Abandoned callfollow-upPostcall marks the call incomplete and no human transfer connectedArcher call record (all CRMs)2 min after the call endsAssistant: picks up what the caller needed, can bookTransfer connected · caller blocked · opted out · legacy dropped-call text is on (see 09.4)
Job updatereschedule noticeAppointment start moves by 30 min or moreServiceTitan poll · FieldEdge job.updatedImmediately, deferred to quiet-hours endAssistant: offers another time if the new one failsChange made within 15 min of booking (echo) · appointment already started
Technician en routedispatch textAppointment status becomes DispatchedServiceTitan poll · FieldEdge workorder eventImmediatelyAcknowledge: thanks them, flags the office if they need somethingEvent older than 2 h when seen · appointment start already passed · already sent for this appointment
Job canceledrebook invitationJob status becomes CanceledServiceTitan poll · FieldEdge job.canceled15 min after (lets the office undo)Assistant: rebooksRebooked within the delay · canceled by Archer at the caller's request
Review requestGoogle reviewJob status becomes CompletedServiceTitan poll · FieldEdge job.completed2 h after completion (configurable)Acknowledge: a complaint in reply is flagged to the office, never arguedSame customer asked in last 180 d · no review link configured · balance still owed (optional) · excluded job types (warranty, callback)
Outstanding balancereminderInvoice balance above the floor, N days after the jobServiceTitan daily scan (accounting/v2/invoices)Day 7, then day 21 (configurable), 9 AM localAssistant: answers "how do I pay", hands disputes to the officeBalance now zero · below $ floor · reminder cap reached
6-month follow-upcheck-inLast completed job was 6 months ago, nothing sinceServiceTitan daily scan9 AM localAssistant: books the check-upActive membership (Maintenance Members owns them) · any job in the window · sent in last 12 months

Default messages

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.

Abandoned call+2 min
Hi {{first_name}}, this is {{business_name}}. Looks like we got disconnected, sorry about that! Reply here with what you need and we'll get you taken care of, or call us at {{office_phone}}.
first_name falls back to "there"
Job updateon change
{{business_name}}: Your appointment has been updated to {{appointment_date}} at {{appointment_time}}. Reply here if that doesn't work and we'll find another time.
Technician en routeon dispatch
{{business_name}}: Good news, {{first_name}}. {{tech_name}} is on the way and should arrive between {{arrival_window}}. Reply here if anything changes.
arrival_window renders "1:00 – 3:00 PM"; falls back to "shortly"
Job canceled+15 min
{{business_name}}: Your appointment on {{appointment_date}} has been canceled. Reply here anytime to get back on the schedule.
Review request+2 h
Thanks for choosing {{business_name}}, {{first_name}}! If {{tech_name}} took good care of you, a quick Google review would mean a lot: {{review_link}}
review_link is required; no link, no send
Outstanding balanceday 7 · day 21
{{business_name}}: Friendly reminder that there's a balance of {{balance}} on your recent service. You can pay at {{payment_link}} or reply here with any questions.
without a payment link the sentence becomes "reply here and we'll help you settle it"
6-month follow-up9 AM local
Hi {{first_name}}, it's {{business_name}}. It's been about six months since we were out for your {{service_name}}. Want us to schedule a check-up? Reply YES and we'll find a time.
Opt-out footerfirst text per recipient
… Reply STOP to opt out.
STOP is honoured by smsRecipientHealth; sticky until an Archer-team re-enable
04

Dashboard mockups


Four 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.

Screen A Revenue Recovery → Touchpoints → Overview. Grouped by lifecycle stage, one row per touchpoint, switch on the left, "Edit" opens Screen B.

Revenue Recovery

Recover missed revenue with targeted outbound campaigns
DashboardOutreachTouchpointsCampaignsConfiguration
OverviewActivity
Texts sent · 30d
1,284
7 touchpoints on
Replies handled
96
7.5% reply rate
Jobs booked from replies
23
$14.2k invoiced
Balances collected
$6,180
11 invoices, after a reminder
Before the visit call and scheduling moments
TouchpointTriggerSent · 30dRepliedOutcomeLast sent
Abandoned call follow-up
Text a caller who dropped before we finished
Archer call21241 19%14 bookedToday 2:14 PMEdit
Job update
Confirm a moved appointment time
ServiceTitan879 10%3 rescheduledToday 11:02 AMEdit
Technician en route
"Marcus is on the way, arriving 1–3 PM"
ServiceTitan60418 3%Today 2:31 PMEdit
Job canceled
Invite a canceled customer back onto the schedule
ServiceTitan3911 28%6 rebookedYesterdayEdit
After the visit reviews, payment, and coming back
Review request
Ask for a Google review 2 h after the job closes
ServiceTitan2486 2%Today 1:40 PMEdit
Outstanding balance
Remind on day 7 and day 21 until paid
ServiceTitan588 14%11 paidToday 9:00 AMEdit
6-month follow-up
Check in with non-members six months after their last job
Needs setupNeverSet up
Quiet hours 8:00 AM – 8:00 PM America/Chicago. 14 texts are waiting for the morning window. Reply STOP is honoured on every touchpoint. Change in Configuration → Touchpoints.
Screen B Edit sheet for Technician en route. Template with variable chips, reply mode, skip rules, live preview.

Revenue Recovery

Recover missed revenue with targeted outbound campaigns
DashboardOutreachTouchpointsCampaignsConfiguration
OverviewActivity
Before the visit call and scheduling moments
Abandoned call follow-up
Text a caller who dropped before we finished
Archer call2124114 bookedToday 2:14 PMEdit
Job update
Confirm a moved appointment time
ServiceTitan8793 rescheduledToday 11:02 AMEdit
Technician en route
"Marcus is on the way, arriving 1–3 PM"
ServiceTitan60418Today 2:31 PMEdit

Technician en route

Sent the moment ServiceTitan marks the appointment Dispatched.
{{business_name}}: Good news, {{first_name}}. {{tech_name}} is on the way and should arrive between {{arrival_window}}. Reply here if anything changes.
first_namebusiness_nametech_namearrival_windowappointment_dateoffice_phone
148 characters. Insert a variable by clicking it. "Reply STOP to opt out." is added automatically on a customer's first text from you.
Send me a test text
CancelSave
Screen C Touchpoints → Activity. Every send with its status reason, and the reply thread for a selected row.

Revenue Recovery

Recover missed revenue with targeted outbound campaigns
DashboardOutreachTouchpointsCampaignsConfiguration
OverviewActivity
All touchpoints ▾Any status ▾Last 7 days ▾Export CSV
WhenCustomerTouchpointMessageStatus
2:31 PMRiley Nguyen
+1 (512) 555-0184
Technician en routeComfort Air: Good news, Riley. Marcus is on the way and should arrive between 1:00 – 3:00 PM…Sent
2:14 PMDana Okafor
+1 (512) 555-0117
Abandoned callHi Dana, this is Comfort Air. Looks like we got disconnected, sorry about that! Reply here…Replied · Booked
1:40 PMTom Bradley
+1 (512) 555-0102
Review requestThanks for choosing Comfort Air, Tom! If Priya took good care of you, a quick Google review…Sent
1:12 PMMaria Castillo
+1 (512) 555-0143
Job updateComfort Air: Your appointment has been updated to Thu, Sep 10 at 8:00 AM…Waiting · quiet hours
11:02 AMJ. Whitfield
+1 (737) 555-0166
Review requestSkipped · asked 41 d ago
9:00 AMSam Reyes
+1 (512) 555-0190
Outstanding balanceComfort Air: Friendly reminder that there's a balance of $312.50 on your recent service…Replied · Paid
9:00 AMA. Petrov
+1 (512) 555-0121
Outstanding balanceSuppressed · opted out
Hi Dana, this is Comfort Air Heating & Cooling. Looks like we got disconnected, sorry about that! Reply here with what you need and we'll get you taken care of, or call us at (512) 555-0100. Reply STOP to opt out.2:14 PM · touchpoint
yeah my AC stopped cooling, I was trying to get someone out tomorrow2:19 PM
Sorry to hear that. I can get a technician out tomorrow, Sep 8. We have 8–10 AM or 1–3 PM open. Which works better?2:19 PM · assistant
8-10 is good2:21 PM
You're set for tomorrow between 8 and 10 AM at 4410 Duval Rd. You'll get a text when the tech is on the way.2:21 PM · assistant
Outcome
Booked ServiceTitan job #48233
Original call
Sep 7, 2:09 PM · 1 m 48 s
Reply mode
Assistant
Sent from
+1 (833) 555-2335
Event id
abandoned_call_15125550100_CA7e3…
Screen D Configuration → Touchpoints. Shared settings in Quiet Slate rows; the Archer-team card is the enrollment gate.

Revenue Recovery

Recover missed revenue with targeted outbound campaigns
DashboardOutreachTouchpointsCampaignsConfiguration
Core SettingsSequencingEstimate Follow-UpTouchpointsConnections

Sending

Rules that apply to every touchpoint. Individual messages and reply handling live under Touchpoints → Overview.
Quiet hours
No touchpoint is sent outside this window in your timezone (America/Chicago). Waiting texts go out when it opens; en-route texts are skipped instead of delayed.
8:00 AMto8:00 PM
Daily limit
Most touchpoints a single day may send. Technician en-route texts don't count toward it.
200
Contact cap per customer
A customer receives at most this many touchpoints in 7 days. En-route texts are exempt.
3
Opt-out footer
Append "Reply STOP to opt out." the first time you text a customer. Required for compliance; the Archer team can change this.
On

Links

Used by the review request and balance reminder templates.
Google review link
The "Write a review" link for your Business Profile. The review request stays off until this is set.
https://g.page/r/CbX…/review
▸ How to find your review link
Online payment link
Optional. Without it the balance reminder asks the customer to reply instead.
https://

Timing

Review request delay
Hours after a job is marked complete.
2
Balance reminder schedule
Days after the job for each reminder. Stops as soon as the balance is paid.
721
Balance floor
Balances under this amount are never chased.
$25
Follow-up interval
Months since the last completed job before the check-in goes out.
6
Touchpoints Archer team
Touchpoints enabled for this account
Master gate, read on every dispatch. Off stops everything already scheduled. Stored as sourceToggles.touchpoints.
Own the dropped-call text
When on, the Abandoned call touchpoint replaces the legacy Post-Call "dropped calls" message so the caller is never texted twice.
Sender
The number touchpoints go out from.
+1 (833) 555-2335 shared

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.

05

How a touchpoint moves


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.

Sources
Postcall stepabandoned call, after isCallIncomplete and the transfer decision
all CRMs
ServiceTitan pollerevery 5 min per enabled tenant: jobs + appointments modified since cursor, diffed against last-seen status
onSchedule · touchpointCursors
FieldEdge webhookexisting handler; add workorder. to the accepted prefixes and route job/workorder events here
already persisted
Daily scanbalance reminders and 6-month check-ins, 9 AM tenant time
onSchedule, like estimate follow-up
Normalize
touchpointEvents / {id}id = kind + tenant + CRM entity (+ cadence step). Create-if-absent, so a poll that sees the same dispatch twice, or a webhook redelivery, is a no-op.
status: scheduled
onTouchpointEventCreatedcomputes scheduledFor = now + delay, pushed to quiet-hours end; enqueues a Cloud Task on the existing sequence queue pattern
Dispatch guards
GateOutbounding enrolled · sourceToggles.touchpoints · this kind enabled
ConsentsmsRecipientHealth opted out / suppressed · tenant blocked list · disableNumber · testing mode
Budgetdaily limit · per-customer 7-day cap · review once per 180 d
Still true?re-read the CRM entity: appointment not started, job not rebooked, balance still owed. Stale → canceled
Rendertemplate + whitelist variables; missing required → suppressed: missing_variable
Send and after
Retell create-sms-chatoverride_agent_id + dynamic variables; intro_message is the rendered text verbatim; prompt chosen by reply mode
chatId stored on the event
Replyinside the session, the agent answers. After the session closes, /retell-inbound-sms falls back to the latest touchpoint event for that customer when no lead matches
Outcomechat end → booked / rescheduled / paid / office-flagged, written to the event; Activity and Overview read from it

Reply modes, concretely

06

Data model


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.

packages/types/src/touchpoints.ts (proposed)
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.

07

Guard rails that are new to the platform


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.

08

Where it lands in the tree


AreaNewChanged
Shared typespackages/types/src/touchpoints.tspackages/shared/src/notifications.ts: add touchpoint_sms so the abandoned-call send shows in the call-log Notification Status card
Backend functionsfunctions/src/touchpoints/: types, render, guards, dispatch (Cloud Task target), schedule (onCreated), sources/servicetitan-poll, sources/daily-scan, promptsfunctions/src/index.ts: register the three functions; outbound-utils.ts: reuse executeOutboundSms with a literal-opener option and the queue helper
Backend serversrc/call/postcall/steps/touchpointAbandonedCall.tsdroppedCallText.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)
FieldEdgepackages/fieldedge-core/src/webhook-event.ts: accept workorder.; web webhook handler forwards job/workorder events to the touchpoint ingest
Webcomponents/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
Firestorethree composite indexes
Docsarchitecture-call-flows-and-functions.md (new section + diagram), glossary.md (touchpoint; outreach vs campaign vs touchpoint), ./scripts/check-glossary.sh run
Testsrender, 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
09

Decisions I need from you


Each has a recommendation so a single "go with your recs" is a complete answer. Anything you change, I'll fold into the build.

1
Name. Touchpoints, Service Updates, or Customer Notices?
Rec: Touchpoints.
2
Transport. Everything through Retell SMS chat with a literal opener (one path, replies always answered, no delivery receipts), or Twilio for the one-way kinds (receipts, but replies go nowhere until an inbound handler exists)?
Rec: Retell only for v1; revisit if en-route volume makes chat sessions costly.
3
Sender number. Touchpoints will go out from the shared 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?
Rec: yes, and open a separate ticket for per-tenant senders.
4
Abandoned call vs the legacy dropped-call text. When the touchpoint is on, suppress the Post-Call "dropped calls" message for that tenant so nobody gets two texts. Or keep them independent and let CSMs manage it?
Rec: touchpoint owns it when on; the Post-Call settings page shows a note pointing at Touchpoints.
5
v1 CRM scope. Abandoned call for every tenant; job/appointment/invoice kinds for ServiceTitan (poller) and FieldEdge (webhooks). Housecall Pro, Jobber, GHL later.
Rec: as stated. ServiceTitan first, FieldEdge in the same release if the workorder event carries dispatch status; otherwise FieldEdge en-route waits.
6
Review request gating. Should it wait for the invoice to be paid, and should it exclude warranty/callback job types by default?
Rec: don't wait for payment by default (most shops collect on site); exclusions on, editable.
7
Defaults. Quiet hours 8 AM – 8 PM local, daily limit 200, 3 touchpoints per customer per week, balance reminders on day 7 and 21 with a $25 floor, 6-month interval, review delay 2 h.
Rec: as listed.
8
Polling cadence for ServiceTitan. 5 minutes per enabled tenant is the en-route latency floor. Fine for v1, or do you want 2 minutes for tenants with en-route on?
Rec: 5 min, one function, tenants fanned out inside it; tighten later with evidence.
9
Rollout. Ship behind the Archer-team switch, pilot with one ServiceTitan tenant that already runs estimate follow-up, expand after a week of Activity review.
Rec: as stated. Pilot tenant is your pick.
10

Build order


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.

Phase 1
Foundation
  • Types, settings, events, dispatcher, guards, render
  • Overview, Edit sheet, Activity, Configuration screens
  • Abandoned call touchpoint + legacy ownership
  • Inbound fallback + reply prompts
  • Docs + glossary
Phase 2
ServiceTitan events
  • 5-minute poller with cursor + last-seen diff
  • Tech en route, Job update, Job canceled
  • Review request + review link setting
  • Staleness re-checks
Phase 3
Dated scans
  • Outstanding balance (invoices API, cadence, floor)
  • 6-month follow-up (membership exclusion)
  • Payment link setting
  • Dashboard tab strip
Phase 4
FieldEdge + polish
  • Accept workorder events; map job.* to kinds
  • Per-touchpoint test send
  • CSV export
  • Opt-out check lifted into Outreach (separate PR)