Campaigns
A campaign is a sequence in your code: what to try, in order, and the goals and exits that end it for a lead at any moment. We place the calls, and keep the rules no code can change.
import { defineCampaign, defineQueue, offer } from '@hcc/sdk';
export const sales = defineQueue('sales', { skills: { sales: 1 } });
export const vips = defineQueue('vips', { skills: { vip: 1 } });
export const renewals = defineCampaign<{ stage?: string; vip?: boolean }>('renewals')
.goal((lead, event) => event.disposition === 'meeting_booked' || lead.fields.stage === 'customer')
.exit((_lead, event) => event.disposition === 'not_interested')
.call(renewalFlow) // the flow takes the call, with no rep
.wait('30m')
// Your provider: we don't send SMS. It can run more than once, so pass `eventId` along.
.do((lead, { eventId }) =>
sms.send(lead.phone, 'Sorry we missed you.', { idempotencyKey: eventId }),
)
.wait('3d')
.call((lead) => (lead.fields.vip ? vips : sales)) // a rep takes the call
.onVoicemail((call) => call.play('https://cdn.example.com/sorry-we-missed-you.mp3').hangup());Name the shape of lead.fields with defineCampaign<MyFields>. It isn't checked at runtime, and a CSV's values arrive as strings.
Turn it on
hcc deploy deploys it. A new one starts paused in every environment, test included, so a deploy never starts it dialing.
- Turn it on in the dashboard (Campaigns, open it, Resume), or with
hcc.campaigns.resume(renewals)from a script. - Deploying again never changes the status of one that exists: a running campaign stays running.
Add leads
Leads come in through the API or a CSV. A lead needs a timezone to be dialed.
await hcc.leads.upsertMany([{ phone: '+14155550123', name: 'Ana', timezone: 'America/New_York' }], {
campaign: 'renewals',
});hcc.leads.upsert overwrites the lead with the same ref, or the same number if you give none. To add only, pass { ifExists: 'refuse' }: if a lead has that ref, or with no ref any lead has that number, a CRM's included, nothing is written and it throws HccApiError with code: 'lead_exists' and the leads in details.leads. A new ref on a number with leads adds a second lead, as people share lines. The dashboard's Add lead form adds the same way.
Leads you already have don't enter by themselves: see Existing leads.
Choose the steps
A rep, a flow or an AI agent can be any step, the first included. A campaign with no steps is one call on your only queue.
| Step | What happens |
|---|---|
.call(queue) | Keeps a free rep, dials the lead, and joins the rep when they answer. The rep sees nothing until then. |
.call(flow) | Dials with no rep, so only your limits hold it back. The flow takes the call, with c.input.lead. An AI agent is a flow that connects one. |
.call(offer(queue)) | Shows the lead on a rep's desk, and their click dials it. Reps only see dialing when your code asks. A lead they skip waits 5 minutes. |
.call(handler) | Runs ahead of the dial, in batches, so a slow server never slows one. Returns a target, or c.to(target, { from, input }), or c.skip() for the next step. |
.wait('3d') | A lead in a wait uses no memory and makes no calls. Good to the minute. |
.do(handler) | Any code, off the dial path. It can run more than once: ctx.eventId says it's the same run. Pass it along to whatever you call. If it keeps failing, the lead parks as step_failed. |
Read next
- How a lead moves: what ends a sequence, and the Event when a lead leaves.
- Who a campaign is for: filter leads with
.enroll, and who comes back. - Existing leads: backfill the leads you already have.
- What a rep sees: holds and
.screenPop. - Voicemail on a campaign: detecting machines and leaving a message.
- Running a campaign: floor rules, hooks, parked leads and tests.