Call campaigns
A call campaign is a Campaign row with type = call. It owns a set of CampaignTarget rows,
each one phone number with its own state machine.
The /campaigns list shows call campaigns only. Opening a voice campaign URL for a campaign
whose type is email redirects to the email campaign detail page.
Configuration
| Field | Default | Meaning |
|---|---|---|
|
|
|
|
|
Targets leased per broadcast tick, and the basis of the in-flight ceiling |
|
|
Retry ceiling per target. Fallbacks differ by code path — see the note below |
|
|
Cooling-off hours before a recoverable target is dialable again |
|
|
Time-of-day window, evaluated in the target’s timezone |
|
|
When false, Saturday and Sunday are skipped in the target’s timezone |
|
|
Preferred outbound number |
|
|
Preferred analysis agent for this campaign’s calls |
|
|
How often |
|
— |
Owning workspace |
|
Set |
Campaign lifecycle
stateDiagram-v2 [*] --> draft draft --> running running --> paused paused --> running running --> stopped running --> completed stopped --> [*] completed --> [*]
Only a running campaign broadcasts. processBroadcast throws
CallInitiationError('CAMPAIGN_NOT_RUNNING') for anything else, so pausing a campaign stops
new dials on the next tick without touching leased targets.
The broadcast loop
POST /api/campaigns/{id}/broadcast is an internal-only endpoint gated by
verifyInternalSecret(). pg_cron calls it on the campaign’s interval. The route passes a
lease of one day (60 × 60 × 24 seconds) because a queued call can wait hours before Retell
dials it; the lease is cleared when the call ends.
flowchart TD
Cron["pg_cron tick"] --> Route["POST /api/campaigns/{id}/broadcast\n(internal secret)"]
Route --> Svc["PhoneNumberCallingService.processBroadcast(campaignId, 86400)"]
Svc --> S1{"Campaign exists\nand status = running?"}
S1 -->|No| Err["Throw CallInitiationError"]
S1 -->|Yes| S2["Supabase RPC get_and_lease_due_targets\n(batch = maxConcurrentCalls, lease = 86400s)"]
S2 --> S3{"Any targets?"}
S3 -->|No| Idle["Return hasAvailableTargets: false"]
S3 -->|Yes| S4{"Redis in-flight >= maxConcurrentCalls x 2?"}
S4 -->|Yes| Throttle["Return throttled — retry next tick"]
S4 -->|No| S5["toEnqueue = min(targets, maxInflight - currentRuns)"]
S5 --> S6["Per target: check TargetPhoneNumbers registry"]
S6 --> S7{"Eligible?"}
S7 -->|No| Skip["state = skipped, clear lease, record lastError"]
S7 -->|Yes| S8["inngest.send call-target/call.requested\nthen incrementCallTargetRuns"]
Target leasing
get_and_lease_due_targets is a Supabase RPC (a PostgreSQL function). It atomically selects up
to p_batch_size due targets and stamps leaseExpiresAt = now() + p_lease_seconds, so two
concurrent ticks cannot lease the same row. A crashed worker releases its claim by expiry
rather than needing cleanup.
The lease skips a target whose TargetPhoneNumbers row has isBlocked = true. Resolution matches
the broadcaster: the targetPhoneNumbersId foreign key when it is set, otherwise the phone number
plus the campaign workspace, ignoring deleted registry rows. A target with no registry row stays
eligible. Deleted and high-risk rows are still refused after the lease.
In-flight throttling
A call is in-flight from the moment the Inngest event is sent until the provider webhook lands, which the database cannot see cheaply. A Redis counter bridges that window:
-
Ceiling is
maxConcurrentCalls × 2. The doubled headroom accommodates a target that has a double tap queued alongside its original run. -
At or above the ceiling the tick returns
throttledand enqueues nothing. -
Below it, only
maxInflight - currentRunsevents are enqueued even if more targets were leased. Surplus leased targets simply wait for their lease to expire. -
incrementCallTargetRunsruns after each successfulinngest.send;callTargetdecrements on every exit path.
Registry gate at lease time
Before enqueuing, the broadcaster resolves the target’s TargetPhoneNumbers row (by
targetPhoneNumbersId, else by number within the campaign’s workspace) and refuses to dial when:
| Condition | lastError recorded |
|---|---|
Registry row belongs to another workspace |
|
|
|
|
|
|
|
In all four cases the target moves to skipped and its lease is cleared. A target with no
registry row at all is allowed through — the registry is an enrichment layer, not a
prerequisite.
The dial pipeline
The callTarget Inngest function (retries: 0, concurrency: { limit: 5 },
rateLimit: 2 per targetPhoneNumber per 24h) performs three steps. retries: 0 is deliberate:
a dial attempt is a discrete business event, and retrying it automatically would double-dial a
prospect.
-
check-call-window—checkCallWindow(campaignId, targetId). Outside the window or on a disallowed weekend, exit withskipReason: outside_call_window. The lease is not cleared, so the target is simply re-picked after expiry. -
pre-validate—preValidateTargetOnly(…), see below. -
initiate-call—initiateCall(…): select a source number, pick a caller agent, create the Retell call, then upsert theCallrow and set the target todialingin one transaction.
Every exit path calls a finish() helper that fire-and-forgets
decrementCallTargetRuns(campaignId).
Pre-validation guards
Evaluated in order; the first match wins.
| Guard | Check | Result |
|---|---|---|
Target exists |
|
|
DNC |
Any target with this phone number is on the DNC list |
|
Prior terminal disposition |
A previous call to this number had a disposition in |
|
Contact lifecycle |
The linked contact’s stage is in |
|
E.164 format |
|
reason |
Setting attempts = 9999 is a belt-and-braces measure: even if the state were later reset, the
attempt ceiling would keep the target out of the dial pool.
Amazon_Seller appears in SKIP_CALL_DISPOSITIONS because a confirmed Amazon seller has already
been qualified and is handed to sales — re-dialing them is a negative experience, not an
opportunity.
Call initiation
-
Select a source number via
PhoneNumberSelector(see Phone numbers). If none is eligible, a purchase is requested asynchronously and the attempt exits withno_phone_number_available— the next broadcast retries. -
Pick a caller agent: a random active
caller-typeAiAgentfor the workspace, falling back to the staticRETELL_AGENT_LISTinscripts/retelai/agents/registry.ts. -
Build metadata.
targetPayloadvalues are stringified and null-coalesced to"", because a Retell agent will read a missing variable’s name aloud. -
Create the Retell call with
campaignId,campaignName,campaignTargetId,agentIdandagentVersionIdin bothmetadataandretell_llm_dynamic_variables. The webhook handler relies onmetadata.campaignIdandmetadata.campaignTargetIdto correlate the call. -
On a Retell error: set the target to
failed, incrementattempts, recordlastError, and returnskippedwithretell_errorrather than throwing — a provider outage should not fill Sentry with unhandled exceptions. -
On success:
upsertCallAndSetTargetDialingwrites theCallrow (statusdialing,providerCallId,aiAgentId,aiAgentName,agentVersionId,phoneNumberId) and moves the target todialingin a single transaction.
|
The |
Coverage
GET /api/campaigns/{id}/coverage reports how well the workspace’s outbound numbers cover the
campaign’s target geography — which states have targets but no local source number.
POST /api/campaigns/{id}/coverage/recommendations asks an LLM
(lib/ai/services/coverage-recommendation-service.ts) which area codes to buy next. See
Coverage endpoints.