# Outbound Sales Agent

> A paced outbound dialer that originates calls with an AI agent, handles no-answer and voicemail, and transfers hot leads to a human closer.

You will build a complete outbound sales flow with a **paced dialer**. The
dialer does five things:

1. It walks a list of leads.
2. It originates each call over a SIP trunk with an **AI qualifying agent**
   attached.
3. It obeys a calls-per-second rate and a max-concurrency ceiling.
4. It records the outcome of every attempt (answered / no-answer / busy /
   failed).
5. When the agent qualifies a lead, it **transfers the hot call to a human
   closer**. The `sid` that keys your recording and analytics stays the same.

Everything below runs on the [Voice SDK](/modalities/voice/sdk/javascript). The
control plane is request/response. The dialer is therefore an `originate` +
outcome-poll loop wrapped in two limiters. The agent drives the conversation
server-side. 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). `max_concurrent` therefore means *simultaneous live
calls*, exactly like a predictive dialer. Pacing and the AI leg are
independent. You configure the agent, its providers, and its transfer tool
server-side (see [Runtime overview](/modalities/voice/runtime/overview)). The
dialer decides only *who* to call and *how fast*.

> **WARNING:**
> Outbound sales dialing is regulated. **You** are responsible for
>   calling-hours windows, caller-ID accuracy, and consent. You must also scrub
>   every number against your suppression / Do-Not-Call list *before* the number
>   reaches the dialer. The platform does not do this for you. This recipe
>   assumes that the `numbers` you enqueue below are already consented and
>   DNC-clean.

## Step 1 — the qualifying agent

The dialer attaches an agent by id. You configure the agent's brain
(speech-to-speech or cascaded ASR→LLM→TTS), its system prompt, and its tools
once, in the console or via the control plane. You do not configure them inline
in `originate`. Give the agent a **telephony transfer tool**. The agent can
then route a qualified lead to your human closer queue itself. This is the
recommended "hot lead" path. See
[Step 4](#step-4-transfer-hot-leads-to-a-human).

> **NOTE:**
> You attach an agent by **id**. `originate({ 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](/modalities/voice/runtime/byo-speech-to-speech),
>   [tool calling](/modalities/voice/runtime/tool-calling).

## Step 2 — the paced dialer

The dialer combines two limiters over the plain `calls.originate` call:

- a **calls-per-second** gate, so that you never originate faster than the
  trunk / carrier allows, and
- a **max-concurrency** semaphore, so that no more than N calls are live at
  once.

The dialer originates each lead with the agent attached. It then polls the call
with `calls.get` until the call reaches a terminal state. Only then does the
dialer release the slot.

  
```ts
import { Voice, type CallStatus } from "clutchcall_connect/voice";

const v = new Voice({
  baseUrl: "https://engine.firstbyte.fast",
  apiKey:  process.env.CLUTCHCALL_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
    },
  },
);
```
  
  
```python
import os, time, threading, concurrent.futures as cf
from clutchcall.voice import Voice

v = Voice(
    base_url="https://engine.firstbyte.fast",
    api_key=os.environ["CLUTCHCALL_CREDENTIALS"],
    org_id="org_abc",
)

TERMINAL = {"completed", "failed", "no_answer"}

def dial_one(lead, *, from_, trunk_id, agent, ring_timeout_sec, on_outcome):
    call = v.calls.originate(
        to=lead["phone"], from_=from_, trunk_id=trunk_id,
        agent=agent, ring_timeout_sec=ring_timeout_sec,
    )
    answered = False
    while True:
        c = v.calls.get(sid=call.sid)
        if c.status == "in_progress":
            answered = True            # agent is on the line
        if c.status in TERMINAL:
            outcome = ("answered" if answered
                       else "no_answer" if c.status == "no_answer" else "failed")
            on_outcome(lead, outcome, call.sid)
            return
        time.sleep(0.5)

def run_campaign(leads, *, from_, trunk_id, agent,
                 calls_per_second=2, max_concurrent=20,
                 ring_timeout_sec=25, on_outcome=lambda *_: None):
    min_gap = 1.0 / calls_per_second
    gate = threading.Semaphore(max_concurrent)   # live-call ceiling
    pace = threading.Lock()
    last = [0.0]

    def worker(lead):
        try:
            dial_one(lead, from_=from_, trunk_id=trunk_id, agent=agent,
                     ring_timeout_sec=ring_timeout_sec, on_outcome=on_outcome)
        except Exception:
            on_outcome(lead, "failed", None)
        finally:
            gate.release()

    with cf.ThreadPoolExecutor(max_workers=max_concurrent) as pool:
        futures = []
        for lead in leads:
            gate.acquire()               # block until a slot frees up
            with pace:                   # CPS gate
                wait = last[0] + min_gap - time.monotonic()
                if wait > 0:
                    time.sleep(wait)
                last[0] = time.monotonic()
            futures.append(pool.submit(worker, lead))
        cf.wait(futures)

# ── run it ──────────────────────────────────────────────────────────────
def record(lead, outcome, sid):
    print(lead["phone"], "→", outcome, sid or "")
    # persist to your CRM: disposition, retry queue, sid for the recording

run_campaign(
    [
        {"phone": "+15551234567", "name": "Dana R."},
        {"phone": "+15557654321", "name": "Sam K."},
    ],
    from_="+15558675309",
    trunk_id="trunk_sales",
    agent="outbound-sales",
    calls_per_second=5,
    max_concurrent=50,
    on_outcome=record,
)
```
  

> **TIP:**
> The server clamps `ringTimeoutSec` to **5..120** (default 30). A short ring
>   window (20–25 s) improves list throughput. It also cuts down on calls that
>   roll to voicemail. See [Step 3](#step-3-no-answer-busy-and-voicemail).

## Step 3 — no-answer, busy, and voicemail

Every attempt lands in exactly one terminal `CallStatus`. The dialer uses that
status to classify the outcome:

| `status`       | Meaning                                    | Typical disposition       |
| -------------- | ------------------------------------------ | ------------------------- |
| `in_progress`  | Callee answered; the agent is talking      | live — keep the slot      |
| `completed`    | The call was answered and then hung up normally | answered → CRM outcome    |
| `no_answer`    | Rang out / ring-timeout / declined         | retry later               |
| `failed`       | Busy, rejected, unallocated, carrier error | inspect, maybe suppress   |

Poll `calls.get` as the dialer does above. If you prefer not to poll, consume
[call-lifecycle events](/modalities/voice/transport-telephony/call-lifecycle)
(`initiated → ring → established/cleared`, each with a Q.850 cause). Then drive
the same state machine off the event stream.

> **WARNING:**
> There is **no built-in answering-machine detection (AMD)**. A call that
>   voicemail picks up reports `in_progress`, the same as a human answer. Handle
>   voicemail in the agent, not the dialer:
> 
>   - Give the agent a short **idle watchdog**. Dead air or a long unbroken
>     greeting then triggers a soft prompt and then a hang-up (see
>     [Turn detection](/modalities/voice/concepts/turn-detection)).
>   - Prompt the agent to recognise a voicemail greeting. The agent then drops
>     the call, or leaves a single scripted message and ends the call.
>   - Keep `ringTimeoutSec` low. Fewer calls then 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` stays the same across the transfer. The recording, transcript, and
analytics therefore stay on one timeline.

### Recommended: let the agent route to a closer queue

The cleanest path is a **warm handoff driven by the conversation**. Configure
the agent with a telephony transfer tool. When the agent decides that the lead
is hot, *it* routes the qualified call into a human skill queue on the ACD.
Your dialer process stays out of the audio path entirely.

```text
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 path uses the agent's tool-calling surface and the ACD picker. Set them up
in [Tool calling](/modalities/voice/runtime/tool-calling) and
[PBX / ACD routing](/modalities/voice/transport-telephony/pbx-acd).
[AI → human handoff](/modalities/voice/transport-telephony/ai-human-handoff)
and the [Support handoff recipe](/modalities/voice/recipes/support-handoff)
cover the full warm-handoff mechanics.

### Manual: transfer from your own logic

Your process can decide when to escalate (for example, off a scoring webhook).
In that case, use `call.transfer` directly. Pass a **PSTN number** to
blind-transfer the audio to a closer's phone (a SIP `REFER` on the wire). Or
pass an **agent id** to re-attach the call to a different AI agent in place.

  
```ts
// 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" }));
```
  
  
```python
# Blind-transfer the live call to a human closer's phone. Same sid continues.
v.calls.get(sid=sid).transfer(to="+15550112233")

# …or re-attach to a specialist AI agent instead of a human:
v.calls.get(sid=sid).transfer(agent="enterprise-closer-bot")
```
  

> **NOTE:**
> `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. That 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). The engine
then owns the pacing. There is no long-running loop on your side. Reach the
campaign dialer from the console's campaign screen or the control-plane API.
Use the SDK pacer in this recipe when you need programmatic control. Use the
built-in campaign dialer when you need only throughput.

## Next steps

  - **[Outbound calls](/modalities/voice/transport-telephony/outbound)** — The `originate` reference: every param, agent attach, and outcome polling.
  - **[AI → human handoff](/modalities/voice/transport-telephony/ai-human-handoff)** — How the warm transfer to a human closer works end to end.
  - **[Tool calling](/modalities/voice/runtime/tool-calling)** — Give the agent a route-to-skill tool. The agent then escalates hot leads
    itself.
  - **[Turn detection](/modalities/voice/concepts/turn-detection)** — Idle watchdog and barge-in: your voicemail and dead-air backstop.
  - **[Call lifecycle](/modalities/voice/transport-telephony/call-lifecycle)** — Consume events instead of polls to drive the outcome state machine.
  - **[Support handoff recipe](/modalities/voice/recipes/support-handoff)** — The sibling recipe: routing a qualified call into a human queue.
