Apps

Dial

Bucket-based outbound calling — work through a queue of contacts with scripts, call notes, and live activity tracking.

Dial is the outbound-calling mode. Where SMS is a thread-based inbox-style interface, Dial is a bucket-based queue: you organise leads into buckets, the dialler advances through them, and every call records activity, notes, and follow-up state.

Dial opens as a native shell surface — full-bleed, no React Flow canvas underneath. The dialer body lives in DialerBody and is mounted by DialerSurface (native-apps/registry.js); the legacy DialWidget node type is still registered so any canvas you've already populated with a Dial widget keeps rendering. See Canvases and widgets → native apps vs canvases.

On the native surface, while a lead is actively loaded the contact card lifts out of the columns row into a full-height left sidebar (matching the Calendar and Data native layouts), and the dialer header rides the chrome's darker top bar over the remaining columns via the shared NativePaneChrome. Empty / closed / error states have no contact card, so DialerSurface falls back to the plain header-over-content stack. The canvas mount (DialWidget) keeps the contact card inline as the first column — the full-height-sidebar arrangement is the native opt-in (nativeLayout).

The contact column's resize is wired against the right container: in native layout the sidebar's parent is the surface itself (it lives outside the columns row), so the resize clamp measures the whole surface; the lead-details and scripts resizes drop the contact-card term from their max-width math when the contact column isn't in the row.

Dial shares the Telnyx phone-number setup with the SMS but the workflow is shaped for volume outbound rather than 1:1 conversations.

Buckets

A bucket is a list of contacts (from the People table) that you're working through. Buckets live in src/store/dialBuckets.jsx and the Bucket Builder Sheet (DialBucketBuilderSheet.jsx) is where you configure them — pick contacts via filter on the People table, set priority, set today's quota.

The sheet header reads Edit Bucket when editing an existing bucket and New Bucket when creating; saving closes the sheet cleanly (the internal reset effect now gates on a false → true isOpen transition, so parents re-emitting a fresh initialConfig after save no longer snap the user back to the Source step mid-close).

Save flows from the builder sheet are wired straight into the live dialer:

  • Create → auto-switch. Saving a brand-new bucket flips activeBucketId to the new one; the existing source-table effect loads its rows and the cursor lands at the top — no extra click to start dialling.
  • Edit the active bucket → advance. Saving filter / cap / cooldown / sort edits to the bucket you're currently dialling flashes the lead-advance skeleton and calls handleAdvanced() so the change takes effect with the next lead. Edits to other buckets don't touch the active dialer.
  • Edit an eligibility rule → live refresh. Adding, editing, or deleting an eligibility_rules row publishes on the new eligibility_rules_changed activity-bus channel, which drives a metadata refetch in every open dialer scoped to the same org (and, for bucket-scoped rules, the matching bucket). A freshly-added "180-min cooldown on No Contact" rule now starts filtering on the next cursor move — no operator-side disposition required to wake the metadata up.

The current on-screen queue lead is still pinned past every filter via pinnedRowIdRef.current (intentional carve-out at DialWidget.jsx:629-633) so a mid-call filter / rule change never yanks the operator off a lead. The exclusion takes effect the moment they disposition or skip. The pin is session-only — closing the tab or refreshing clears it, so a lead can't stay carved out of its bucket across reloads.

The Fields step's "Lead Details panel — pick columns to display" grid prefixes each pickable column with the same type icon (COL_TYPE_META) that the live Lead Details panel uses, so the builder and the panel visually match. The Sort step keeps its GripVertical since that's a real drag affordance, not a column-type indicator.

The Review step shows the bucket's full shape before save with readable filter chips (tag UUIDs resolved to names via useTags, same-operator tag filters grouped into one row), every active sort (not just the first), the bucket's timezone, the live eligibility rule count (fetched for existing buckets, derived from drafts for new ones), and a jump-to-step Edit button per section.

The Bucket List widget (DialBucketListWidget.jsx) is one of the dashboard-style widgets that remain canvas-addable — drop it on Home or any board to surface every bucket with:

  • Bucket name and icon
  • Today's call count vs. quota
  • Recent-calls preview
  • Click to open / start dialling

Range operator on date filters

Date and datetime columns expose an in period (range) operator in the bucket filter UI, rendered with the shared RangeValueInput (period dropdown + custom from/to). Five built-in periods — This week, Last week, This month, Last month, Custom range — and the boundary math runs once per pass via precomputePeriodRanges, so the window stays stable across all rows in a single evaluation. The same primitive backs the data-table widget's date filter (see Data app → range filter).

Ghosted-leads rollup

System datetime column on every user_table_rows row, populated by an AFTER trigger on calendar_events (migration 122). It holds the MAX(start_at) of no_showed events on the lead that haven't been superseded by a later attended event or a later upcoming pending event — i.e. it tracks open ghosts only. Once a lead recovers (attended) or rebooks (upcoming pending), the rollup clears to NULL automatically. A partial index (table_id, last_open_no_show_at DESC) WHERE NOT NULL keeps the index lean since most leads never ghost.

Pair it with the range operator to build buckets like Ghosted — Last week (Last ghosted (open) · in period · Last week) with no code change, just UI config.

Eligibility rules

The Cadence tab's EligibilityStep lets you stage blank rules locally until they get a trigger_disposition_id, then inserts on commit — Add rule never fires a draft insert against PostgREST's NOT-NULL constraints. Existing rules are inserted/updated as you go.

Saved rule changes broadcast on the eligibility_rules_changed activity-bus channel, so every open dialer scoped to the same org (and the matching bucket, for bucket-scoped rules) refetches its eligibility metadata immediately. The change takes effect on the next cursor advance, not on the operator's next disposition.

The Dial widget

The main DialWidget.jsx is the actual dialling surface. It shows:

  • Header search — type a name, email, or phone number to jump to any lead across every bucket (see below)
  • Next-meeting badge — a live countdown chip in the header showing the operator's soonest upcoming linked meeting, sitting left of the daily-progress bar. Format adapts to how close the meeting is: mm:ss under 5 min, in 14m under 1 h, in 2h 14m under 24 h, Tomorrow 9:00 AM further out, and Now while in progress. Tick cadence is 1 s when imminent and 30 s otherwise — owned by the badge itself so the rest of the header doesn't re-render every second. Clicking the chip loads the linked lead into the dialer and pins it past the active-event filter so the row lands in the queue even when its own appointment would normally hide it. The pin is session-only and clears on disposition / skip — refreshing the tab drops it (this is the only surface that still pins; header search no longer does). Backed by the next_upcoming_appointment_for_current_user RPC (migration 124) — SECURITY INVOKER, scoped server-side to auth.uid() so callers can't peek at other users' calendars. Live updates ride the existing eventLinkBus + a 60 s safety refetch (calendar_events isn't on the supabase_realtime publication).
  • Current contact — name, company, last-contacted-at, conversation count
  • Lead details panel — full row from the People table. Each field label is prefixed with the column's type icon (phone, envelope, hash, $, calendar, etc., sourced from COL_TYPE_META) so the shape of the row reads at a glance. Double-click any cell to inline-edit: text / number / phone / email / price / percent inputs auto-focus with their value selected; select, multi-select, link, and tags pickers auto-open on first double-click (one-click pick); date / datetime / time triggers focus and open on Enter or Space; boolean focuses its first option button. Double-clicking inside an active input is a no-op so word-select doesn't reset the draft to the original value. Columns whose type isn't in COL_TYPE_META (e.g. json) render without an icon and don't break layout. Sensitive columns (see column-level encryption) render masked with an Eye / EyeOff toggle — clicking the eye calls the audited user_table_row_reveal_sensitive RPC to decrypt a single value, and double-clicking after reveal opens the inline editor as normal. Advancing to the next lead re-masks every sensitive field automatically; nothing decrypted leaks across leads.
  • Scripts panel — pre-written talk tracks you can step through
  • Call note composer — draft notes during the call; auto-saved as drafts via useDialNoteDraft and persisted on completion. The input sits in the same focus-within card chrome as the record-sheet activity composer, so the two reads as one primitive.
  • Result rail — the call outcome (Connected / No answer / Voicemail / Wrong number / etc.) that drives where the contact goes next. The manual advance button now reads Skip (matches the tooltip + the action's intent).
  • Activity panel — recent calls and events for the current lead only. Backed by useRecordActivities, which captures the active (rowId, tableId) per refetch and drops late-arriving writes from the previous lead — so dispositioning a call and advancing never shows the prior lead's activity on the next screen.
  • Calendar tab — schedule a follow-up directly from the call. Events created from a slot click in this tab are auto-linked to the active lead (calendar_event_links row created in the same transaction as the event), so the existing active-event filter sees the new appointment and suppresses the lead from the queue without a manual relink step.
  • Email tab — every Gmail / Outlook thread to-or-from the current lead's email address, listed by subject + last-message snippet + label chips + time, with a drill-in reader that reuses the inbox's paper-card visuals. The header carries Reply / Reply All / Forward ghost buttons that open the floating composer with a blank body (no >>>>> quote chain) via a noQuote option on buildReplySeed. State is isolated from the main Inbox — opening a thread here doesn't touch the inbox's activeThread, search, or folder. The hook (useDialEmailForLead) is React Query–backed: it fans out across every connected email integration in parallel, merges + dedupes results, and caches the per-lead thread list so re-visiting a lead reads from cache instead of re-fetching. Thread metadata is prefetched on lead advance (via useDialPrefetch) and thread bodies prefetch on hoveronMouseEnter / onFocus on each email row warms the cache before the click. Per-integration page size is 100 results to cover the long-tail without paginating.
  • SMS tab — a single-conversation view for the current lead, backed by an API-key SMS provider. A three-card picker exposes PitchPrfct (active), TextDrip (coming soon), and Project88 (coming soon); selecting PitchPrfct opens the conversation via the pitchprfct-api edge-function proxy and a reusable <SmsMessageBubble> shared with the canvas SMS widget. Four pre-conversation gates (no phone, unparseable number, connection loading, not connected → opens ApiKeyConnectModal) plus three in-conversation error states (auth → Reconnect, rate-limit → inline retry banner, generic → "Try again") cover the full state machine. Sending requires a fromNumber — read from localStorage['dial.sms.pitchprfct.fromNumber'] until a settings UI lands. The composer mirrors the record sheet's Log-activity pattern (Enter-to-send). Bubbles render with whitespace-pre-wrap break-words so multi-line composer drafts keep their newlines and long URLs wrap instead of overflowing. The stream groups messages by calendar day with date dividersToday / Yesterday / May 26th, 2026-style labels — and inbound messages stream in via a 5-second poll (paused while an optimistic outbound bubble is in flight so the poll can't clobber an in-progress send). SMS history loads 200 messages per fetch. See PitchPrfct integration.

useDialAdvance handles the queue logic — finishing a call advances to the next contact in the bucket, respecting the result rail's branching. The page of candidate leads itself comes from useDialQueue (server-paged via dialer_next_leads); see Server-side queue and authoritative leasing.

After a disposition or skip, the Contact / Lead Details / Activity panels hold a layout-matched skeleton for the 350 ms min-display window (and longer while recordDialActivity is in flight), so the next lead reads as a deliberate beat rather than an imperceptible flip. The Call History sheet also opens onto a skeleton list mirroring CallHistoryRow and re-flashes the skeleton on filter changes.

SMS / Email cache and prefetch

The SMS and Email tabs sit on a shared React Query cache mounted at the app root (QueryClientProvider). Switching tabs or returning to a recently dialed lead reads from cache instead of re-fetching — first click is near-instant. Behavior:

  • Per-data-type staleTime. SMS and email queries are tuned so the cache survives lead advance and tab switching; the PitchPrfct connection check uses staleTime: Infinity and is shared across every surface that needs it, so remounting the SMS tab doesn't re-issue api.integrations.list().
  • Tiered prefetch on lead advance. useDialPrefetch fires when the cursor moves to a new lead and warms the cheap RPCs in the background — SMS contact + messages, plus email thread-list metadata. Thread bodies stay lazy and prefetch on hover.
  • Optimistic SMS sends. Outbound messages render an optimistic bubble immediately; the cache is updated directly via queryClient.setQueryData so the bubble survives the 5-second inbound poll until the server confirms.

Header search (Search mode)

The header search sits next to the bucket selector and is the operator's escape hatch from the auto-cycle queue. Picking a result opens an ephemeral Search mode that displays the lead without touching the underlying bucket queue.

  • Cross-bucket. The search input scans every distinct source table any bucket points at (eager-loaded via ensureRowsLoaded). Matches from the current bucket are listed first; matches from other buckets appear under an Other buckets section header. Picking a result switches bucket if needed and jumps to the lead.
  • Phone normalization. Phone-typed columns get a digits-only fallback so 5207109378 matches a stored (520) 710-9378. The same lib/searchMatch.js helper now backs both dialer search and data-table search.
  • Always-on appointment filter. A server-side RPC (dialer_active_events_by_rows) joins calendar_event_linkscalendar_events on end_at > now() to skip leads with an active linked calendar event — across every bucket, with no time horizon. On RPC failure the dialer fails open with a visible amber "Couldn't check appointments" banner so the operator knows the safety net is down.
  • Search mode is ephemeral — it does not pin. Picking a search result sets searchedLead = { bucketId, rowId, outsideFilters } rather than pinning the row into the queue. The lead displays via the bucket's render config (field map, scripts, detail columns) but never enters the queue and never bypasses filters, cap, cooldown, or the active-appointment check. A header Search pill shows the lead name, an "outside filters" hint when the row wouldn't normally match the current bucket, and an exit button back to the queue.
  • Disposition / skip returns to the queue. Recording a result against the searched lead writes the activity against that lead, then drops the operator back into the bucket's auto-cycle where they left off — Search mode clears automatically.
  • No persistence across reloads. Header search no longer writes to localStorage. Reloading clears Search mode entirely; the cursor restores to the bucket's queue position only. Legacy persisted pinnedRowIds records from older clients are ignored on read so no lead can stay trapped past a filter change after a refresh.

Server-side queue and authoritative leasing

The dial queue is computed in Postgres, not in the browser. The client used to bulk-load every row in the source table and apply filters / cap / cooldown / sort in JS — fine at a few thousand rows, unworkable past that. As of migrations 142–146 the next-lead algorithm runs server-side, paginates with a keyset cursor, and reserves leads with an authoritative per-row lease so two operators on the same bucket can't double-dial.

dialer_next_leads — server-side queue (migrations 142, 143, 146)

  • app_hidden.dialer_filter_predicate (migration 142) is an injection-safe SQL mirror of the JS matchesFilter. The bucket's column / tag filter tree compiles to a WHERE clause built with format(%L), so a filter tree typed in the UI becomes a real Postgres predicate at lead-fetch time rather than a JS pass over a fully-loaded table.
  • dialer_next_leads (migrations 143 / 146) is the page-fetch RPC: it applies the bucket's filter, sort, per-record cap, cooldown, archived-row exclusion, and teammates'-lease exclusion server-side, paginates with a keyset cursor, and accepts an include_row_ids carve-out so a session pin (mid-call lead or next-meeting badge) still surfaces past every filter. The RPC returns data_enc alongside data so the panel can mask sensitive fields without a second round-trip.
  • dialer_queue_stats (migration 145) backs the header daily-progress bar with matching_count (size of the filtered queue) and a capped calls_today.
  • All three RPCs are SECURITY INVOKER so RLS continues to enforce org / table scope; no new security-advisor warnings.

useDialQueue — keyset-paged client hook

useDialQueue replaces the old bulk ensureRowsLoaded for the dial loop. It fetches a page from dialer_next_leads, holds the keyset cursor, fetches the next page automatically as the operator advances, and supports includeRowIds for the session-pin carve-out. Writes during a call (patchRow) update data and data_enc in place so the active lead's masked / revealed fields stay coherent across inline edits.

Schedule, eligibility, and the active-event filter remain JS post-filters over the page — they're cheap to evaluate per row and they keep the cursor reactive to in-session changes. Header search and viewing an off-queue record still rely on ensureRowsLoaded for now; a follow-up server-side search RPC + lazy record fetch will retire that path.

Authoritative leasing — dialer_lead_leases (migration 144)

Multi-agent coordination is now an authoritative server-side lease, not a presence broadcast.

  • dialer_lead_leases is a per-(bucket, row) lease row with a TTL.
  • dialer_claim_lead is a conditional upsert: exactly one caller wins under concurrency. The dialer claims the lead on dial.
  • dialer_release_lead releases the lease on advance or disposition. The widget also heartbeats every minute so a crashed tab can't hold a lease forever — leases expire on TTL.
  • dialer_next_leads already excludes teammates' active leases from the page, so a leased row never appears in another operator's queue. The "entire-call double-dial" race that the old presence soft-lock could only narrow to sub-second is now closed at the database.

The Supabase Realtime presence channel is retained for the live "contested" UI chip only — if two operators briefly contend for the same row in the same tick, the loser sees an amber "Also dialing: ‹name›" indicator while the lease resolves. State is held in the lease table; presence is only a hint for the chrome.

Activity sync (unchanged)

useRecordActivitiesRealtime still subscribes each open dialer to record_activities filtered by the bucket's source table and bridges remote INSERT / UPDATE / DELETE events into the activityBus, so today-counts, cooldowns, and the activity feed update within ~1 s without a manual reload. Requires migration 121_record_activities_realtime_publication.sql.

Pin / anti-yank contract

The mid-call session pin (pinnedRowIdRef.current) still protects the active queue lead from filter changes — include_row_ids in dialer_next_leads carves the pinned row past the server-side predicate, and the JS post-filters (schedule, eligibility, active-event, bucket-filter tree) honour the same pin. An in-call edit that flips the current lead out of the filter tree — e.g. adding a tag that matches tags not_has DNC — keeps the lead on screen until the operator dispositions or skips, at which point the pin clears and the now-excluded row drops on the next page fetch.

Search mode displays a lead without ever entering the queue, so it doesn't participate in the pin contract. record_activities remains the authoritative source of truth for what's been dialed; dialer_lead_leases is authoritative for what's currently being dialed.

The Dial Stats widget

DialStatsWidget (in dial-stats/) renders calls-today, conversion, and a disposition breakdown for a chosen bucket. Each widget carries a per-node user scope — persisted on the canvas node — so a single canvas can show multiple scoped views side by side:

ScopeWhat it counts
EveryoneAll calls on the bucket (default — backward compat)
MeThe viewer's own calls. Resolves at render time, so a shared canvas shows each viewer their own numbers
A teammatePick any org member from the UserPicker; an avatar/initials chip renders next to the stats
  • Drill-through inherits the scope. Clicking a disposition row opens the history sheet showing only the scoped user's calls, backed by a nullable p_user_id argument on dial_bucket_calls / dial_bucket_today_calls (migration 117). Counts in the widget and rows in the sheet always match.
  • Activity-bus stays scoped. The activity-bus payload carries createdBy, so a scoped useDialStats skips refetches triggered by other users' activity. Two scoped widgets on the same canvas (e.g. one on Me, one on a teammate) only refetch when their own scope's calls change.
  • Stale userIds fail loud. If a teammate is removed from the org while a scoped widget still points at them, the widget renders "Unknown user" with zero totals and a warning chip — never silently falls back to Everyone in a way that would shift numbers without telling the operator.
  • Three sizes. Small shows a glanceable total with a scope marker overlay when scoped; Medium and Large show the full DialStatsControls strip (bucket / scope / window / sheet).

The shared UserPicker primitive — built on the design-system Select — and the org-members store (useOrgMembers) are also used elsewhere across the app.

Components and sheets

The native Dial surface composes the same internal panels the old widget did. The two dashboard-style widgets (DialBucketListWidget, DialStatsWidget) are still addable to other canvases via the global Add Widget menu; everything else is now an internal piece of DialerBody.

ComponentWhere it mounts
DialerSurface / DialerBodyThe native Dial app (DialerBody is also rendered inside the legacy DialWidget node for backwards compat)
DialBucketListWidgetCanvas widget — list of buckets
DialStatsWidget (in dial-stats/)Canvas widget — calls today, conversion, breakdown (per-user scope, see above)
DialActivityPanelInternal — recent activity stream
DialContactPanelInternal — current contact details
DialLeadDetailsPanelInternal — full lead view
DialCallNoteComposerInternal — note draft + save
DialScriptsPanelInternal — talk tracks
DialResultRailInternal — outcome buttons
DialEmailComposeInternal — follow-up email composer
DialCalendarTabInternal — inline calendar for scheduling
DialEmailTabInternal — per-lead inbox in the middle panel
DialSmsTabInternal — per-lead SMS conversation (PitchPrfct)
DialCallHistorySheetModal — full call history
DialBucketBuilderSheetModal — bucket configuration

Dashboard widgets compose with anything else on a canvas — pin a DialStatsWidget next to a CalendarAgendaWidget and a DataStatsWidget on Home to build a per-rep overview without leaving the launchpad.

Where Dial gets its leads

The People table — same as SMS and Campaigns. Bucket configuration is a saved filter against People; new matching contacts surface in the bucket automatically as they're added.

Setup

Phone numbers are configured under Settings → Phone Numbers (shared with SMS). For 10DLC and compliance settings, see the SMS.

Where to next

On this page