SMS
Native SMS app backed by PitchPrfct — full-height inbox rail with tabs and search, thread pane, service picker, and live messages via a HMAC-verified webhook → Supabase Realtime pipeline.
SMS opens as a native shell surface — full-bleed, no React Flow
canvas underneath — mounted by SmsWorkspace and registered in
native-apps/registry.js with kind: 'native'. Opening SMS from the
sidebar mounts the workspace directly; there's no canvas placement
step. See
Canvases and widgets → native apps vs canvases.
The dashboard-style smsInboxWidget canvas widget shares the same
internals and stays addable to any board via smsWidgets — so a
canvas you already dropped the inbox on keeps rendering.
Layout
The native SMS surface is a two-column shape:
- Left — inbox rail (
SmsInboxRail): full-height sidebar layered over the header line, drag-resizable — matches the dialer contact-card sidebar and the calendar mini-month rail. - Right — thread pane: current conversation with a compose bar
pinned at the bottom, under the shared
SMStop bar viaNativePaneChrome.
Empty / disconnected states render the picker and connect affordances in the right pane.
Inbox rail
- Search — debounced server-side across name, phone, and last snippet. Empty state clears back to the paginated list.
- Tabs — All / Unread / Starred, each a server-side filter
(
search,isRead,starred) with an optimistic client mirror so the tab counts stay coherent through read-marking and star toggles. - Rows — contact name (or number), latest snippet, last-message timestamp, unread dot, hover-star toggle. Click a row to load the thread and mark it read.
- Pagination — 25 conversations per page via
useInfiniteQuery, infinite-scroll with a Load More fallback. Replaces the single 50-row pull that took 20 s+ on a heavy account. - Skeletons — layout-matched
SkeletonRegion(glimmering,role="status",aria-busy) while the first page loads or filters change.
Thread pane
Bubbles inbound / outbound with per-day date dividers (Today,
Yesterday, May 26th, 2026-style labels). The same
<SmsMessageBubble> primitive as the dialer's SMS tab, so composer
newlines are preserved and long URLs wrap. Compose sends via
Enter; the outbound bubble renders optimistically and reconciles
with the server confirm.
The composer carries a Templates picker — the same
TemplatePickerPopover used by email compose and the dialer's SMS
tab. Merge fields ({{contact.first_name|there}}) resolve against
the conversation's contact on insert. Image attachments render as
image bubbles in the phone frame; Copy message carries them as
base64 data URIs so a paste into the Messages app reproduces the
template. See Templates app.
The dial widget's SMS tab is the same rendering primitive under a different frame — thread pane only, scoped to the current lead.
Service picker
smsProviders.jsx is a shared catalog wired into both the SMS
app and the dial SMS tab. Cards today:
| Provider | Status |
|---|---|
| PitchPrfct | Active |
| Project88 | Coming soon — first-party SMS |
TextDrip has been removed — it was a placeholder that never shipped. Picking PitchPrfct opens the connect modal if you haven't already; once connected, the inbox rail lists your conversations immediately.
Live messages
Deliveries push into the app in ~1 s via the same pattern as the Gmail push wire:
PitchPrfct webhook → pitchprfct-webhook edge fn → sms_events
→ Supabase Realtime → cache invalidation in useSmsRealtime.
- HMAC-SHA256 verified on
{timestamp}.{body}with a replay window, plus a per-integration URL token. Events are idempotent on delivery id; 30-day retention. - Auto-registration — the first time an operator opens SMS,
a JWT-authed register action resolves the vault API key and
idempotently creates or adopts the PitchPrfct webhook
(
message.*events). No manual dashboard step. - Polling demoted to fallback — thread 20 s, inbox 60 s. The dial widget's SMS tab mounts the same realtime hook.
Where contacts come from
Threads carry the PitchPrfct contact identity (name, phone,
contactUuid). Project88's People table remains the CRM /
outreach source of record; the two aren't merged today. When the
first-party Project88 SMS provider ships, its threads will read
directly from People.
Sending
Outbound sends go through the pitchprfct-api edge-function proxy.
fromNumber is resolved from the conversation itself first (see
Sending: fromNumber resolution)
so a reply stays on the number the thread is already on. Upstream
error messages (opted-out contact, insufficient credits, no sending
number, …) are extracted and carried onto the failed bubble's retry
tooltip.
Under the hood
SmsWorkspace/SmsInboxRail— the native surface shell and the full-height rail with search, tabs, pagination, and star toggles.useSmsRealtime— subscribes each open SMS surface tosms_eventsand drives targeted cache invalidations.- Migration
158(sms_realtime) —sms_events+sms_webhook_state, added to thesupabase_realtimepublication. pitchprfct-webhook— verify_jwt=false; token + HMAC is its auth. Adopts existing PitchPrfct webhooks idempotently.pitchprfct-api— 5xx upstream logging added for the intermittent/conversationshangs.
Where to next
- PitchPrfct integration — connect, vault, webhook, and phone-numbers resolution
- Dial → SMS tab — the per-lead single-thread surface built on the same primitives
- Canvases and widgets —
the
smsInboxWidgetstill drops onto any board
Dial
Bucket-based outbound calling — work through a queue of contacts with scripts, call notes, and live activity tracking.
Templates
Native app for reusable email + SMS messages — folder tree, merge fields with fallbacks, per-channel frames, and attachments (with inline images) shared by email compose, the dial SMS tab, and the SMS app.