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
activeBucketIdto 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_rulesrow publishes on the neweligibility_rules_changedactivity-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:ssunder 5 min,in 14munder 1 h,in 2h 14munder 24 h,Tomorrow 9:00 AMfurther out, andNowwhile 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 thenext_upcoming_appointment_for_current_userRPC (migration124) —SECURITY INVOKER, scoped server-side toauth.uid()so callers can't peek at other users' calendars. Live updates ride the existingeventLinkBus+ a 60 s safety refetch (calendar_eventsisn't on thesupabase_realtimepublication). - 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 fromCOL_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 inCOL_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 auditeduser_table_row_reveal_sensitiveRPC 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
useDialNoteDraftand persisted on completion. The input sits in the samefocus-withincard 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_linksrow 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 anoQuoteoption onbuildReplySeed. State is isolated from the main Inbox — opening a thread here doesn't touch the inbox'sactiveThread, 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 (viauseDialPrefetch) and thread bodies prefetch on hover —onMouseEnter/onFocuson 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-apiedge-function proxy and a reusable<SmsMessageBubble>shared with the canvas SMS widget. Four pre-conversation gates (no phone, unparseable number, connection loading, not connected → opensApiKeyConnectModal) plus three in-conversation error states (auth → Reconnect, rate-limit → inline retry banner, generic → "Try again") cover the full state machine. Sending requires afromNumber— read fromlocalStorage['dial.sms.pitchprfct.fromNumber']until a settings UI lands. The composer mirrors the record sheet's Log-activity pattern (Enter-to-send). Bubbles render withwhitespace-pre-wrap break-wordsso multi-line composer drafts keep their newlines and long URLs wrap instead of overflowing. The stream groups messages by calendar day with date dividers —Today/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 usesstaleTime: Infinityand is shared across every surface that needs it, so remounting the SMS tab doesn't re-issueapi.integrations.list(). - Tiered prefetch on lead advance.
useDialPrefetchfires 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.setQueryDataso 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
5207109378matches a stored(520) 710-9378. The samelib/searchMatch.jshelper now backs both dialer search and data-table search. - Always-on appointment filter. A server-side RPC
(
dialer_active_events_by_rows) joinscalendar_event_links⋈calendar_eventsonend_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
pinnedRowIdsrecords 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 JSmatchesFilter. The bucket's column / tag filter tree compiles to aWHEREclause built withformat(%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 aninclude_row_idscarve-out so a session pin (mid-call lead or next-meeting badge) still surfaces past every filter. The RPC returnsdata_encalongsidedataso the panel can mask sensitive fields without a second round-trip.dialer_queue_stats(migration 145) backs the header daily-progress bar withmatching_count(size of the filtered queue) and a cappedcalls_today.- All three RPCs are
SECURITY INVOKERso 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_leasesis a per-(bucket, row)lease row with a TTL.dialer_claim_leadis a conditional upsert: exactly one caller wins under concurrency. The dialer claims the lead on dial.dialer_release_leadreleases 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_leadsalready 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:
| Scope | What it counts |
|---|---|
| Everyone | All calls on the bucket (default — backward compat) |
| Me | The viewer's own calls. Resolves at render time, so a shared canvas shows each viewer their own numbers |
| A teammate | Pick 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_idargument ondial_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 scopeduseDialStatsskips 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
DialStatsControlsstrip (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.
| Component | Where it mounts |
|---|---|
DialerSurface / DialerBody | The native Dial app (DialerBody is also rendered inside the legacy DialWidget node for backwards compat) |
DialBucketListWidget | Canvas widget — list of buckets |
DialStatsWidget (in dial-stats/) | Canvas widget — calls today, conversion, breakdown (per-user scope, see above) |
DialActivityPanel | Internal — recent activity stream |
DialContactPanel | Internal — current contact details |
DialLeadDetailsPanel | Internal — full lead view |
DialCallNoteComposer | Internal — note draft + save |
DialScriptsPanel | Internal — talk tracks |
DialResultRail | Internal — outcome buttons |
DialEmailCompose | Internal — follow-up email composer |
DialCalendarTab | Internal — inline calendar for scheduling |
DialEmailTab | Internal — per-lead inbox in the middle panel |
DialSmsTab | Internal — per-lead SMS conversation (PitchPrfct) |
DialCallHistorySheet | Modal — full call history |
DialBucketBuilderSheet | Modal — 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
- SMS — same provider, different channel and workflow
- Telnyx SMS integration
- Data tables — the People table