You’ll build a complete outbound sales flow: a paced dialer that walks a list of leads, originates each call over a SIP trunk with an AI qualifying agent attached, honours a calls-per-second rate and a max-concurrency ceiling, records the outcome of every attempt (answered / no-answer / busy / failed), and — when the agent qualifies a lead — transfers the hot call to a human closer without losing the sid that keys your recording and analytics. Everything below runs on the Voice SDK. The control plane is request/response, so the dialer is just an originate + outcome-poll loop wrapped in two limiters. The conversation itself is driven server-side by the agent — your process never touches audio.

Architecture at a glance

 lead list ──▶ paced dialer (your process)
                 │  CPS limiter + concurrency semaphore

        v.calls.originate({ agent: "outbound-sales" })   ── control plane

                 ▼  engine dials the carrier over your SIP trunk
        ┌───────────────── outcome ─────────────────┐
        │ no_answer · failed · completed            │  ── you record + move on
        │ in_progress (answered) ──▶ AI agent runs  │
        │      qualifies lead ──▶ route_to_skill    │  ── warm transfer to a
        │                          (human closer)   │     human on the ACD
        └───────────────────────────────────────────┘
The dialer holds a concurrency slot for the whole life of a call (dialing → answered → cleared) so max_concurrent means simultaneous live calls, exactly like a predictive dialer. Pacing and the AI leg are independent: the agent, its providers, and its transfer tool are configured server-side (see Runtime overview); the dialer only decides who to call and how fast.
Outbound sales dialing is regulated. You are responsible for calling-hours windows, caller-ID accuracy, consent, and scrubbing every number against your suppression / Do-Not-Call list before it reaches the dialer — the platform does not do this for you. The numbers you enqueue below are assumed to be already consented and DNC-clean.

Step 1 — the qualifying agent

The dialer attaches an agent by id; the agent’s brain (speech-to-speech or cascaded ASR→LLM→TTS), its system prompt, and its tools are configured once in the console or via the control plane — not inline in originate. Give the agent a telephony transfer tool so it can route a qualified lead to your human closer queue itself (the recommended “hot lead” path — see Step 4).
Agents are attached by idoriginate({ agent: "outbound-sales" }) and agents.attach(sid, "outbound-sales") both take a string. Provider keys, prompt, turn-detection, and the transfer tool live in the agent’s server-side config. Build the agent first: BYO speech-to-speech, tool calling.

Step 2 — the paced dialer

The dialer combines two limiters over the plain calls.originate call:
  • a calls-per-second gate so you never originate faster than the trunk / carrier allows, and
  • a max-concurrency semaphore so no more than N calls are live at once.
Each lead is originated with the agent attached, then polled with calls.get until it reaches a terminal state; the slot is released only then.
import { Voice, type CallStatus } from "@telequick/sdk/voice";

const v = new Voice({
  baseUrl: "https://engine.telequick.dev",
  apiKey:  process.env.TELEQUICK_CREDENTIALS!,
  orgId:   "org_abc",
});

// Terminal states: the attempt is over and the slot can be reused.
const TERMINAL = new Set<CallStatus>(["completed", "failed", "no_answer"]);

interface Lead { phone: string; name?: string }
type Outcome = "answered" | "no_answer" | "failed";

interface DialerOpts {
  from: string;
  trunkId: string;
  agent: string;
  callsPerSecond?: number;   // pacing gate  (default 2)
  maxConcurrent?: number;    // live-call ceiling (default 20)
  ringTimeoutSec?: number;   // give up ringing after N s (default 25)
  onOutcome?: (lead: Lead, outcome: Outcome, sid?: string) => void;
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function runCampaign(leads: Lead[], opts: DialerOpts) {
  const cps       = opts.callsPerSecond ?? 2;
  const maxLive   = opts.maxConcurrent ?? 20;
  const minGapMs  = 1000 / cps;

  let inFlight = 0;
  let lastOriginate = 0;

  // Acquire a concurrency slot, respecting the CPS gate before each dial.
  async function acquire() {
    while (inFlight >= maxLive) await sleep(50);
    const wait = lastOriginate + minGapMs - Date.now();
    if (wait > 0) await sleep(wait);
    lastOriginate = Date.now();
    inFlight++;
  }

  const dialing: Promise<void>[] = [];

  for (const lead of leads) {
    await acquire();
    dialing.push(
      dialOne(lead, opts)
        .catch(() => opts.onOutcome?.(lead, "failed"))
        .finally(() => { inFlight--; }),
    );
  }
  await Promise.all(dialing);
}

// Originate one lead with the agent attached, then poll to a terminal state.
async function dialOne(lead: Lead, opts: DialerOpts) {
  const call = await v.calls.originate({
    to:             lead.phone,
    from:           opts.from,
    trunkId:        opts.trunkId,
    agent:          opts.agent,          // attaches on answer
    ringTimeoutSec: opts.ringTimeoutSec ?? 25,
  });

  let answered = false;
  for (;;) {
    const c = await v.calls.get({ sid: call.sid });
    if (c.status === "in_progress") answered = true;  // agent is on the line
    if (TERMINAL.has(c.status)) {
      const outcome: Outcome =
        answered ? "answered" : c.status === "no_answer" ? "no_answer" : "failed";
      opts.onOutcome?.(lead, outcome, call.sid);
      return;
    }
    await sleep(500);
  }
}

// ── run it ────────────────────────────────────────────────────────────────
await runCampaign(
  [
    { phone: "+15551234567", name: "Dana R." },
    { phone: "+15557654321", name: "Sam K." },
    // …up to your whole list
  ],
  {
    from:           "+15558675309",
    trunkId:        "trunk_sales",
    agent:          "outbound-sales",
    callsPerSecond: 5,
    maxConcurrent:  50,
    onOutcome: (lead, outcome, sid) => {
      console.log(`${lead.phone}${outcome}`, sid ?? "");
      // persist to your CRM: dispositions, retry queue, sid for the recording
    },
  },
);
ringTimeoutSec is server-clamped to 5..120 (default 30). A short ring window (20–25 s) both improves list throughput and cuts down on calls that roll to voicemail — see Step 3.

Step 3 — no-answer, busy, and voicemail

Every attempt lands in exactly one terminal CallStatus, which is how the dialer classifies the outcome:
statusMeaningTypical disposition
in_progressCallee answered; the agent is talkinglive — keep the slot
completedCall answered then hung up normallyanswered → CRM outcome
no_answerRang out / ring-timeout / declinedretry later
failedBusy, rejected, unallocated, carrier errorinspect, maybe suppress
Poll calls.get as the dialer does above, or — if you’d rather not poll — consume call-lifecycle events (initiated → ring → established/cleared, each with a Q.850 cause) and drive the same state machine off the stream.
There is no built-in answering-machine detection (AMD). A call picked up by voicemail reports in_progress just like a human answer. Handle it in the agent, not the dialer:
  • Give the agent a short idle watchdog so dead air or a long unbroken greeting triggers a soft prompt and then a hang-up (see Turn detection).
  • Prompt the agent to recognise a voicemail greeting and either drop the call or leave a single scripted message, then end the call.
  • Keep ringTimeoutSec low so fewer calls reach voicemail in the first place.

Step 4 — transfer hot leads to a human

When the agent qualifies a lead, hand the live call to a human closer. The sid is preserved across the transfer, so the recording, transcript, and analytics stay on one timeline. The cleanest path is a warm handoff driven by the conversation: configure the agent with a telephony transfer tool so it routes the qualified call into a human skill queue on the ACD the moment it decides the lead is hot. Your dialer process stays out of the audio path entirely.
outbound-sales agent  ──(qualifies lead)──▶  route_to_skill("closers")


                                    ACD picks an available closer
                                    (browser softphone or SIP deskphone)

                                              ▼  screen-pop with lead context
                                    human closer takes the call
This uses the agent’s tool-calling surface and the ACD picker — set it up in Tool calling and PBX / ACD routing. The full warm-handoff mechanics are covered in AI → human handoff and the Support handoff recipe.

Manual: transfer from your own logic

If your process decides when to escalate (for example off a scoring webhook), use call.transfer directly. Pass a PSTN number to blind-transfer the audio to a closer’s phone (a SIP REFER under the hood), or an agent id to re-attach the call to a different AI agent in place.
// Blind-transfer the live call to a human closer's phone. Same sid continues.
await v.calls.get({ sid })
  .then((call) => call.transfer({ to: "+15550112233" }));

// …or re-attach to a specialist AI agent instead of a human:
await v.calls.get({ sid })
  .then((call) => call.transfer({ agent: "enterprise-closer-bot" }));
transfer needs exactly one of to (PSTN, via REFER) or agent (re-attach). A blind PSTN transfer forwards the audio but carries no lead context — for a true warm handoff with a screen-pop, prefer the route-to-skill path above.

Scaling to very large lists

The dialer above paces in your process, which is ideal when each lead needs custom logic (CRM lookups, per-lead scripts, retry policy). For fire-and-forget lists in the tens of thousands, the platform also ships a server-side campaign dialer: you hand the control plane the whole number list plus a template (trunk, agent, caller-id, CPS, max-concurrent) and the engine owns the pacing — no long-running loop on your side. Reach it from the console’s campaign screen or the control-plane API. Use the SDK pacer here when you need programmatic control; use the built-in campaign dialer when you just need throughput.

Next steps

Outbound calls

The originate reference — every param, agent attach, and outcome polling.

AI → human handoff

How the warm transfer to a human closer works end to end.

Tool calling

Give the agent a route-to-skill tool so it escalates hot leads itself.

Turn detection

Idle watchdog and barge-in — your voicemail and dead-air backstop.

Call lifecycle

Consume events instead of polling to drive the outcome state machine.

Support handoff recipe

The sibling recipe: routing a qualified call into a human queue.