Runbooks
Launch a call campaign
-
Create the campaign (
draft) and setmaxConcurrentCalls,maxAttempts,waitHoursToRecall,windowStart/windowEnd,allowWeekendsandpreferredAiAgentId. SetmaxAttemptsexplicitly — its fallback differs between code paths. -
Import targets. Timezones are derived from area codes; a HubSpot contact id may be supplied in the payload as
Record ID,record_id,RecordIdorhubspot_id. -
Check coverage —
GET /api/campaigns/{id}/coverage. States with targets but no local source number will fall back to a non-local number, which lowers pickup rates. -
Confirm the workspace has active
caller-type AI agents; otherwise the staticRETELL_AGENT_LISTfallback is used. -
Set
status = running. -
Create the
pg_cronjob forPOST /api/campaigns/{id}/broadcastwith the internal secret. -
Watch the first ticks:
callsInitiated,callsSkippedand any throttle message in the broadcast response, pluscallTargetruns in Inngest.
Verify the Redis counter is falling back to zero between ticks. If it only rises, callTarget is not
reaching its finish() helper and the campaign will throttle itself into silence.
Pause or stop a campaign
Set status to paused or stopped. processBroadcast throws CAMPAIGN_NOT_RUNNING for anything other
than running, so the next tick stops enqueueing.
Already-enqueued Inngest events still run — pausing does not recall in-flight calls. To stop those, disable
the pg_cron job as well and let the queue drain.
Launch an email campaign
-
Verify the domain in Resend, then create the
Domainrow with itsresendDomainIdfromGET /api/email-domains/resend-domains. -
Add at least one active, verified
EmailSendingDomainunder it, with adailyCap. -
Create the campaign with
schedulerIntervalSeconds,emailsPerHour,maxEmailsPerDayandemailsPerVariationPerHour. -
Add at least one active variation. A campaign with no active variations halts with
throttled: false— the condition never clears on its own. -
Import recipients. Contacts are upserted first, so every target gets a
contactIdand anemailId. -
Send a test through
POST /api/email-campaigns/{id}/sendand confirm rendering, the unsubscribe link and theReply-To. -
Set
status = runningand create thepg_cronjob. -
Watch the broadcast response:
emailsEnqueued,throttled,throttleReason,variationGenerationRequested.
throttled: true clears on its own. throttled: false with zero enqueued means an operator has to act.
Warm a sending domain
-
Create the
DomainwithwarmupStatus = warming_upand a lowdailyCap. -
Add one account with a low
dailyCap. -
Raise both caps gradually over days, watching
bouncedCountandunsubscribedCountper account. -
Set
warmupStatus = activeonce volume is steady. The status is a label — it does not gate sending, so the caps are what actually protect the domain.
A single bad account damages the reputation of its whole parent domain, which is why the parent cap exists as well.
Suppress a recipient
| Goal | Action |
|---|---|
Stop all marketing email to an address |
Set |
Block an address entirely |
Set |
Stop calling a number |
Set |
Stop calling a contact |
Set |
Registry-level blocks apply across every campaign, present and future. Marking a single campaign target
canceled only affects that campaign.
A DNC outcome does the registry block automatically — callEndedAnalysis sets
TargetPhoneNumbers.isBlocked = true.
Publish a new agent prompt
-
Duplicate the agent (
POST /api/ai-agents/{id}/duplicate) if you want to experiment without touching production. -
Review
/agents/issuesfor recurring problems, and usePOST /api/ai-agents/issues/suggest-promptfor a starting revision. -
Run the regression suite —
POST /api/test-cases/runagainst the candidate version.passedis true only when every asserted field matches. -
Publish a new version (
POST /api/ai-agents/{id}/versions). Versions are immutable and the change is mirrored to Retell. -
Watch
/reports/daily-agent-performance—avg_score,correct_disposition_pctandtotal_critical_issuesare per agent version, so the new prompt is directly comparable to the old one.
Re-analyse calls after a prompt change
-
Open the re-analyse dialog on
/callsor a campaign’s Calls tab. -
Set filters — campaign, status, date range, HubSpot sync status.
-
Check the preview count before starting; it uses the same
wherebuilder as the run, so it cannot disagree. -
Start it. Results land as new
CallAiAgentAnalysisResultrows keyed by(callId, aiAgentId, aiAgentVersionId), so the old verdicts are preserved for comparison.
Nooks calls have campaignId = null and are excluded from batch runs — re-run those individually.
Rotate a secret
-
Update the value in Vercel for the target environment.
-
Redeploy — module-load reads mean a running instance keeps the old value.
-
For
INTERNAL_API_SECRET, update everypg_cronjob definition in Supabase in the same window. Broadcasts return401until both sides match. -
For provider webhook secrets, update the provider’s dashboard first, then the environment. A brief window of skipped or failed verification is preferable to a window of dropped events.
Add a carrier
-
Add an entry to
EXTERNAL_PROVIDER_CONFIGSinlib/constants.tswithlabel,source,trunkSidandterminationUrl. -
Add the corresponding environment variables.
-
Deploy.
PhoneNumberCallingService,MessagingAppPhoneService, the purchase route and the Phone Numbers page all read the registry dynamically — no other change is needed.
Release unused numbers
The Inngest function phoneNumberOptimizerCron runs weekly (Monday 11:15 UTC) and releases numbers
unused for 3+ days with fewer than 10 lifetime calls. To run it now, open it in the Inngest dashboard
and choose Invoke. Each release is its own step, so the run page shows which numbers were released
and which failed.
Check /reports/phone-number-pickup-rate first — a number with poor pickup is often better rested than
released, since a release means buying a new one later.
Replay a webhook
select "eventType", "rawPayload"
from "WebhookEvent"
where source = 'Retell' and "providerCallId" = '<call_id>'
order by "receivedAt" desc;
curl the payload back at the endpoint. The WebhookEvent row is idempotent on (source, eventId), but
the handler runs again — which is the point. Downstream, Call.providerCallId uniqueness and the
emailSend status guard absorb the duplicate side effects.