Apps

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 SMS top bar via NativePaneChrome.

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.
  • TabsAll / 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:

ProviderStatus
PitchPrfctActive
Project88Coming 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.

See PitchPrfct → Webhooks.

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 to sms_events and drives targeted cache invalidations.
  • Migration 158 (sms_realtime)sms_events + sms_webhook_state, added to the supabase_realtime publication.
  • pitchprfct-webhook — verify_jwt=false; token + HMAC is its auth. Adopts existing PitchPrfct webhooks idempotently.
  • pitchprfct-api — 5xx upstream logging added for the intermittent /conversations hangs.

Where to next

On this page