# Build a parallel dialer on the Agent Dialer API

A complete blueprint for building a cockpit like CallCloud's own web dialer on top of this API.
Written to be handed to a coding agent in one go: architecture, exact contracts, the UX rules that
fall out of the telephony, and the mistakes that look like bugs later.

Read [INTEGRATION.md](./INTEGRATION.md) first for the API reference. This document is the *how to
assemble it* layer on top.

> **For an AI agent building against this:** the machine-readable spec and both guides are fetchable
> with no auth, so point your agent straight at them.
>
> - OpenAPI: `https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/openapi.yaml`
> - API reference: `https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/INTEGRATION.md`
> - This blueprint: `https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/BUILD-A-DIALER.md`
>
> Generate the client from the spec rather than from prose: it eliminates invented field names.
> Read the prose for the handful of behaviours a schema cannot express, marked **bold** throughout.

---

## 0. What you are building

A screen where a rep clicks **Start**, the system dials several prospects at once, everything that
is not a human is discarded silently, and the rep is talking to a real person within a second of
that person saying hello. The rep never dials, never listens to a voicemail greeting, and never
hears a "connecting…" pause.

That last point is the whole product. Every design decision below exists to protect it.

---

## 1. The architecture in one picture

```
   ┌────────────┐        ┌──────────────────────┐        ┌──────────────┐
   │ your UI    │        │ your backend         │        │ CallCloud    │
   │ (browser)  │        │ (holds cak_ key)     │        │              │
   └─────┬──────┘        └──────────┬───────────┘        └──────┬───────┘
         │ 1. go online             │                           │
         │─────────────────────────>│ POST /browser-token       │
         │<── sw_token, pin, cbt_ ──│──────────────────────────>│
         │ 2. WebRTC + DTMF bind ──────────────────────────────>│  leg is now LIVE and parked
         │                          │                           │
         │ 3. Start                 │ POST /dial parallel=5     │
         │─────────────────────────>│──────────────────────────>│  5 numbers ring at once
         │                          │                           │  machines hung up on
         │ 4. poll /browser-session ────────────────────────────>│  first human -> your leg
         │<── current: {number} ────────────────────────────────│  ~instant, media already open
         │ 5. poll /runs/{id} for line + counter state ─────────>│
```

Two independent polls, and they answer different questions:

| Poll | Auth | Answers |
|---|---|---|
| `GET /browser-session` | `cbt_` (browser) | Am I online? Who am I talking to *right now*? |
| `GET /runs/{run_id}` | `cak_` (your backend) | What is the run doing? Counters, remaining, credits |

Do not try to drive the live-call UI from the run poll. The session poll is the one that changes the
instant a person is connected.

---

## 2. Non-negotiable rule: one leg, one conversation

**A browser leg can hold exactly one conversation.** With `parallel: 5`, five prospects can answer
within the same second. Only the first is connected; the rest are hung up on before they hear
anything.

We enforce this server-side with an atomic claim, so you cannot accidentally connect two people to
one rep. But it dictates your UI:

- Dialing 5 lines does **not** mean 5 simultaneous conversations. It means 5 chances to find one.
- A rep who is talking is not available. Your "lines" display is showing *dialing attempts*, not
  parallel conversations.
- If you want N concurrent conversations, you need **N reps each with their own browser leg**, and
  N separate runs. Do not try to multiplex one leg.

The trade this makes: some real humans get hung up on because someone else answered first. That is
the correct trade for outbound at volume, and it is what every parallel dialer does, but say so in
your own compliance copy rather than discovering it in a complaint.

---

## 3. Choosing parallelism

`parallel` is 1 to 10. Higher is not better:

| Parallel | Feel | Cost of a wrong choice |
|---|---|---|
| 1 | A polite auto-dialer. No abandoned calls. | Rep waits through every ring |
| 3 to 5 | The sweet spot for most lists | Occasional hang-up on a real person |
| 8 to 10 | Aggressive. Only sane on low-connect-rate lists | Many abandoned humans, reputational and regulatory risk |

Pick from the list's connect rate, not from impatience. If roughly 1 in 5 pickups is a human,
`parallel: 5` produces about one conversation per round, which is the target. `get_usage` returns
`outcomes.human_rate`, so you can tune this from real data instead of guessing.

---

## 4. Screening mode, and why it is the latency decision

Covered in INTEGRATION.md section 2. For a cockpit specifically:

- Use **`gate`**. Instant connect is the entire point of this UI, and carrier detection delay is
  paid on every single connect including the real people.
- Use `amd` when the list is unknown and a wrong connect is expensive enough to be worth a pause.

Expose it as a setting, not a constant, and default to `gate`.

---

## 5. Building the cockpit UI

### The states you actually have

```
offline ──go online──> ready ──human connected──> in call ──hang up──> ready
                         │                                              │
                         └──────────────── run finishes ────────────────┘
```

That is it. There is deliberately no "connecting" state, because there is nothing to wait for.

### Screen layout that works

```
┌──────────────────────────────────────────────────────────┐
│  ● Live   Sarah Chen · +1 415 555 0134        02:14      │  <- only when current != null
│  [ Hang up ]  [ Disposition ▾ ]  [ Notes ]               │
├──────────────────────────────────────────────────────────┤
│  Dialing 5 lines   ·  38 of 200 done  ·  6 conversations │  <- from /runs/{id}
│  ▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░  19%         │
├──────────────────────────────────────────────────────────┤
│  Up next                                                 │
│    +1 415 555 0177     +1 415 555 0182                   │
├──────────────────────────────────────────────────────────┤
│  ● Online as you · 812 dials left · gate mode            │  <- status bar, always visible
└──────────────────────────────────────────────────────────┘
```

### Rules that come from the mechanics, not from taste

1. **Render the live call the instant `current` appears.** The person is already speaking. A
   spinner, a fade-in, or a "connecting" toast means the rep misses the first words.
2. **Play an audible cue** when a call lands. The rep is looking at something else. This is the one
   place a sound is genuinely correct.
3. **Show online state from the server**, `browser-session.online`, never from your local WebRTC
   state. The DTMF bind can fail after the WebRTC call connects, and only the server knows.
4. **Make offline impossible to miss.** Offline means every screened-in human gets hung up on. It
   is the most damaging silent failure in the product.
5. **Never block the UI on the run poll.** It is 2 to 3 seconds behind by design.
6. **Request microphone permission when going online**, not on first call.
7. **Disposition after the call, not during.** Do not put a required field between the rep and the
   next conversation.

### Going offline

Call `POST /browser-session` on unmount, on tab close (`visibilitychange` plus `beforeunload`), and
on an explicit control. A leaked leg keeps billing and keeps looking available, so the engine will
route humans into a tab nobody is watching.

---

## 6. The polling loop

```js
// Session poll: fast, drives the live-call UI.
useEffect(() => {
  if (!online) return;
  const t = setInterval(async () => {
    const s = await fetch('/api/agent-dialer/browser-session', {
      headers: { Authorization: `Bearer ${browserToken}` },
    }).then((r) => r.json());
    setOnline(s.online);          // server truth, not local state
    setCurrent(s.current);        // null when nobody is on the line
  }, 2000);
  return () => clearInterval(t);
}, [online, browserToken]);

// Run poll: slower, drives counters. Goes through YOUR backend (cak_ must not reach the browser).
useEffect(() => {
  if (!runId) return;
  const t = setInterval(async () => {
    const r = await fetch(`/api/my-backend/run/${runId}`).then((x) => x.json());
    setRun(r);
    if (r.status !== 'dialing') clearInterval(t);
  }, 3000);
  return () => clearInterval(t);
}, [runId]);
```

The session poll doubles as the heartbeat, so **do not stop it while the rep is on a call**. A leg
that stops polling for 30 seconds is treated as gone.

---

## 7. After the call

`run_results` gives you, per number: `answered_by`, `duration`, `transcript`, and a signed
`recording_url` good for 15 minutes.

- Transcripts land *after* the call ends, once transcription completes. A `done` result with
  `transcript: null` is normal for a short window. Re-fetch rather than treating null as final.
- Machines are never recorded, so no transcript is correct, not missing.
- Mint `recording_url` at render time. Storing it gives you dead links.

---

## 8. Running out mid-list

Two things stop a run without you asking:

| Symptom | Cause | What to show |
|---|---|---|
| `status: "stopped"`, `error: "out_of_credits"` | Balance hit zero | Balance empty, with a buy action. Dials already placed are unaffected |
| `status: "stopped"`, `error: "calling_not_allowed"` | Account moved to pending or suspended | Not retryable; direct them to support |

A run stops at the credit that ran out rather than overshooting, so counters stay truthful. Poll
`credits_remaining` off `run_status` and warn *before* zero, not at it. Auto top-up exists for
exactly this, and `get_usage` reports whether it is armed and whether it has been failing.

---

## 9. Checklist before you ship

- [ ] `cak_` key never reaches the browser. Only `cbt_` and the WebRTC token do.
- [ ] Online state read from `browser-session.online`, not local state.
- [ ] Session poll runs continuously while online, including during a call.
- [ ] `POST /browser-session` on unmount, tab close, and explicit offline.
- [ ] Live call renders immediately, no spinner, with an audible cue.
- [ ] Mic permission requested at go-online.
- [ ] `parallel` chosen from measured `human_rate`, not guessed.
- [ ] `screening: "gate"` unless you have a reason.
- [ ] Out-of-credits and not-approved handled as distinct, non-retryable states.
- [ ] Recording URLs minted at render.
- [ ] Compliance copy acknowledges that parallel dialing abandons some answered calls.

---

## 10. What this API does not give you

Be clear with your own users about the boundary:

- **No CRM.** No contacts, no lists, no dispositions stored for you. You own that data model.
- **No cadence or scheduling.** No retry logic, no calling-hours enforcement, no time-zone gating.
  If you dial someone at 3am, that is your code doing it.
- **No compliance engine.** There is a Do Not Call list — record the outcome and the number is never
  dialed again, by an agent run or by the CallCloud cockpit (INTEGRATION.md, "Do Not Call"). That is
  suppression, not compliance: no consent tracking, no attempt caps, and no connection to the
  federal or state registries. Outbound calling is regulated and those obligations are yours.
- **No AI voice.** A human has the conversation, always.
- **One conversation per browser leg.** Concurrency comes from more reps, not more lines.
