# Agent Dialer: integration guide

Everything needed to build a UI and a backend around the CallCloud Agent Dialer. Written to be
handed to a coding agent as context: contracts are exact, and where behaviour is subtle the reason
is given, because the reason is usually what determines the right UI.

Base URL: `https://dialer.callcloud.app`

> **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`
> - This guide: `https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/INTEGRATION.md`
> - Cockpit 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.


---

## 1. What this product is, in one paragraph

An AI agent places outbound phone calls over MCP. Each number is dialed, the pickup is screened to
weed out answering machines, and a screened-in **human** is connected to a **browser leg** over
WebRTC. There is no AI voice: a real person has the conversation. There is no option to ring a
phone. Connected calls are recorded on split channels and transcribed.

Two consequences that shape any UI you build:

1. **Somebody must be online in a browser before humans can be connected.** A run with nobody
   online screens correctly and then has nowhere to put the humans. Your UI's first job is getting
   the user online and showing them they are.
2. **Dials are prepaid.** Zero balance means the API refuses to start a run. Your UI should show
   the balance wherever someone starts calling.

---

## 2. The two screening modes

This is the only real configuration choice, and it is a latency tradeoff. Expose it deliberately.

| | `amd` (default) | `gate` |
|---|---|---|
| How it decides | Carrier listens to the pickup audio | Ring duration, measured from real ring start |
| Where it decides | After answer, once the carrier has heard enough | On the answer webhook, **before the bridge completes** |
| Connect delay | Detection delay on every connect, real people included | None |
| Catches | Machines a clock cannot, e.g. a human-sounding voicemail greeting | Anything that answers faster than a person plausibly could |
| Tune with | nothing | `gate_ring_seconds` (default 0.5) |

Both **fail open**: an ambiguous or unmeasurable verdict is treated as human and connected. Missing
a machine costs a few seconds; dropping a real person costs the conversation.

Gate mode measures from when the destination *actually started ringing* (the carrier's `ringing`
event), never from dial time, because carrier setup delay varies by destination and counting it as
ring time would gate real people.

**UI guidance.** Present this as "how carefully should we screen" versus "how fast should it
connect", not as a technical toggle. Default to `amd` for unknown lists; offer `gate` when the
first second of the call matters, which is most outbound. If you expose `gate_ring_seconds`, label
it in plain terms: "treat pickups faster than N seconds as voicemail."

---

## 3. Auth model

Three credential types. Getting these wrong is the most common integration bug.

| Credential | Prefix | Where it may live | Used for |
|---|---|---|---|
| Agent API key | `cak_` | **Server only.** Never ship to a browser. | All `/api/agent-dialer/*` calls |
| Browser token | `cbt_` | Browser. Safe by design. | Session heartbeat + current-call poll |
| SignalWire token | (opaque) | Browser | The WebRTC client |

The flow is: your backend holds the `cak_` key, mints a short-lived browser token on behalf of your
frontend, and the frontend only ever sees `cbt_` and the WebRTC token.

Send the API key as `Authorization: Bearer cak_…` (or `X-Api-Key: cak_…`).

---

## 4. Lifecycle

```
your agent                     CallCloud                         the browser
    |                              |                                  |
    |                              |   (1) POST /browser-token  <-----|  backend mints
    |                              |                                  |  frontend goes online
    |                              |                                  |  (WebRTC + DTMF pin bind)
    | (2) dial(numbers, screening) |                                  |
    |----------------------------->|                                  |
    |                              |-- places call, screens pickup    |
    |                              |-- machine -> hang up             |
    |                              |-- human   -> conference ---------|  (3) call appears in poll
    | (4) run_status (poll)        |                                  |
    |----------------------------->|                                  |
    | (5) run_results              |                                  |
    |----------------------------->|  transcript + recording          |
```

Run states: `queued` → `dialing` → `done`, or `stopped` (manual stop, or the run ran out of
credits, or calling was blocked mid-run).

Per-number result states: `queued` → `dialing` → `connected` → `done`, or `failed`.

`answeredBy` on a result: `human`, `machine_start`, `machine_end`, `fax`, `unknown`, `failed`.
**`unknown` is normal and means connected**, because of the fail-open rule. Do not render it as an
error, and do not filter it out of "humans" — `run_results(filter="humans")` already includes it.

---

## 5. API reference

### Start a run

```http
POST /api/agent-dialer/dial
Authorization: Bearer cak_…
Content-Type: application/json

{
  "numbers": ["+15551230000", "+15551230001"],
  "screening": "gate",          // "amd" (default) | "gate"
  "gate_ring_seconds": 0.5,      // gate mode only, 0.2..20
  "caller_id": "+15551119999",   // optional, must be a number you own
  "parallel": 3                  // optional, 1..10
}
```

```json
{ "run_id": "cms…", "status": "dialing", "total": 2 }
```

Errors you must handle in the UI:

| Status | Meaning | What to show |
|---|---|---|
| `400` | Bad input, including a phone number in `connect_to` | The message verbatim; it explains the rule |
| `402` | Out of dial credits | Balance is empty, with a buy action. Body carries `dials_per_pack` and `pack_price_cents` |
| `403` | Account not approved for calling, or lapsed trial with no dials | The message; this is not something a retry fixes |
| `401` | Bad or revoked API key | Re-check server config, never surface the key itself |

Validation runs **before** the credit check, so a `402` always means the request was otherwise
valid. A user who tops up after a `402` will succeed on retry.

### Poll a run

```http
GET /api/agent-dialer/runs/{run_id}
Authorization: Bearer cak_…
```

```json
{
  "run_id": "cms…", "status": "dialing",
  "total": 40, "dialed": 12, "humans": 2, "machines": 7, "no_answer": 3, "connected": 2,
  "remaining": 28,
  "current_call": { "number": "+1…", "answered_by": "unknown", "duration": null },
  "finished_at": null,
  "credits_remaining": 812,
  "error": "out_of_credits"
}
```

Poll every 2 to 3 seconds while `status === "dialing"`. Stop on `done` or `stopped`. `error` is only
present when something ended the run early; `out_of_credits` is the one worth a specific UI.

### Results

```http
GET /api/agent-dialer/runs/{run_id}/results?filter=connected
```

`filter`: `humans` | `machines` | `connected` | `all`. Each row carries `number`, `answered_by`,
`status`, `duration`, `disposition`, `notes`, `transcript`, and `recording_url`.

`recording_url` is a **signed 15-minute link** that needs no auth header, so it can go straight into
an `<audio src>`. It expires, so mint it fresh when rendering rather than storing it.

Paginated: `limit` (default 500, max 1000) and `offset`. The response carries `total` and
`has_more`, so a 5,000-number run pages instead of arriving as one payload.

### Recording an outcome

```http
PATCH /api/agent-dialer/results/{result_id}
{ "disposition": "interested", "notes": "Call back Tuesday" }
```

`disposition` is **free text**. This product ships no CRM and no fixed outcome list, so you own your
taxonomy and we store the string. Send either field; send `null` to clear one. `GET` on the same
path returns the full result including transcript.

A result id belonging to another workspace returns `404`, not a silent success.

### Do Not Call

One disposition does more than store a string. A value that resolves to **Do Not Call** — the
literal `do_not_call` id, `dnc`, or the label of the `do_not_call` entry in your outcome list — adds
that number to the workspace suppression list. Matching is on the whole value, never a substring, so
`not_interested` and `wrong_person` never trip it. The response carries `do_not_call: true` when it
fired.

```http
PATCH /api/agent-dialer/results/{result_id}
{ "disposition": "Do Not Call" }

{ "result_id": "…", "number": "+1…", "disposition": "Do Not Call", "do_not_call": true }
```

Suppression is then enforced on the way back out, in both products:

- `POST /dial` drops suppressed numbers **before** the run is created, so they are never queued and
  never charged. They come back as `skipped_do_not_call` and the run covers the rest. If every
  number was suppressed you get a `400` carrying the same field, rather than an empty run.
- Each number is re-checked immediately before its call is placed, because a run over hundreds of
  numbers outlives the list it was validated against. One suppressed mid-run lands as
  `status: "failed"`, `answered_by: "suppressed"`, uncharged.
- The list is shared with the CallCloud web dialer, the same way the outcome taxonomy below is. A
  prospect marked Do Not Call in the cockpit is never dialed by an agent run, and a number
  suppressed through this API is never dialed by the cockpit.

This is a suppression list, not a compliance engine. It records what you tell it. Consent, the
federal and state registries, and attempt caps remain yours — see BUILD-A-DIALER.md section 10.

### Call outcomes

`set_result` accepts any string, but a dialer UI needs a consistent list to offer or you end up
with "not interested", "Not Interested" and "NI" as three different rows in reporting.

```http
GET /api/agent-dialer/outcomes
PUT /api/agent-dialer/outcomes
{ "outcomes": [ { "label": "Booked a demo", "kind": "positive" }, { "label": "Not a fit", "kind": "negative" } ] }
```

Labels are slugged into stable ids and de-duplicated; max 30. An unconfigured workspace returns a
sensible default set with `is_default: true`, so a picker is never empty on day one.

`kind` (`positive` | `neutral` | `negative`) is **data**, for CRM mapping and analytics, not a
colour. CallCloud's own UI renders every outcome identically on purpose.

Two things worth knowing. This is the SAME list the CallCloud web dialer uses, so a workspace on
both products has one taxonomy and edits in either place apply to both. And this defines what a UI
should *offer*, not what the API will *accept*: `set_result` still takes any string, because
rejecting an unlisted value would strand a call that needs an outcome nobody thought to configure.

### Transferring a live call

```http
POST /api/agent-dialer/results/{result_id}/transfer
{ "to": "+14155551234" }
```

**Warm by construction.** The prospect, the rep's browser leg and the transferee all end up in the
same conference, so the rep can introduce them and then hang up to leave the other two connected.
There is no separate cold mode: a cold transfer is a warm one where the rep leaves immediately.

That works because of how the conference is built. The rep's leg joined with
`endConferenceOnExit="false"`, so the rep leaving does not end the room, while the prospect's leg
carries `"true"`, so the call ends when the prospect hangs up.

**Costs one dial credit.** It is a real outbound leg with real carrier cost. If the leg fails to
place, the credit is refunded.

`409 not_live` if the call is not `connected`; `409 no_conference` if it is not in a room;
`402` if the balance is empty.

### Callbacks

"Call them back Tuesday at 2" is the most common outcome of a real conversation.

```http
POST  /api/agent-dialer/callbacks   { "phone": "+1…", "scheduled_for": "2026-08-04T14:00:00Z", "note": "…", "result_id": "…" }
GET   /api/agent-dialer/callbacks?due=true
PATCH /api/agent-dialer/callbacks/{id}   { "status": "done" }
DELETE /api/agent-dialer/callbacks/{id}
```

**We never auto-dial these.** When to actually call someone back is your orchestration, and a
dialer that surprises people by ringing them on its own is a support incident waiting to happen.
Poll `due=true` for the work queue: it returns pending callbacks whose time has passed, soonest
first, which is the order you want since the most overdue is the most urgent.

`result_id` is optional and validated against your workspace, so a callback can be booked from
anywhere (an inbound request, a form) without a prior call. Marking one `done` stamps
`completed_at`; reopening it to `pending` clears the stamp, so the timestamp never claims a
still-pending callback was handled.

An unparseable `scheduled_for` is a `400`, deliberately: a callback booked at the wrong time is
worse than one that failed to book.

### Listing runs

```http
GET /api/agent-dialer/runs?limit=50&offset=0&status=done&since=2026-07-01T00:00:00Z
```

Paginated history with `total` and `has_more`. `status` and `since` are optional filters; an
unparseable `since` is ignored rather than erroring, so a bad date never fails a dashboard load.

### Stop

```http
POST /api/agent-dialer/runs/{run_id}/stop
```

Halts the run and hangs up anything live. Safe to call on an already-finished run.

---

## 5b. Webhooks: stop polling

Register one endpoint and we push events to it instead of you asking on a timer.

```http
PUT /api/agent-dialer/webhook
{ "url": "https://yourapp.com/hooks/callcloud", "events": ["call.connected", "call.completed"] }
```

Returns the signing `secret`. Omit `events` to receive everything. HTTPS only, except
`http://localhost` so you can develop against a tunnel.

| Event | When | Why you want it |
|---|---|---|
| `call.connected` | A human was put through to the browser leg | Fires while the call is LIVE. The only moment this is actionable |
| `call.completed` | The call ended | Final verdict and duration |
| `call.transcript` | Transcription finished | Lands well after `call.completed`; without this you would re-poll a result that already reads `done` |
| `run.finished` | The run reached `done` or `stopped` | Carries the final counters and `error` |

### Verifying a delivery

Each request carries `X-CallCloud-Event`, `X-CallCloud-Timestamp` and `X-CallCloud-Signature`. The
signature is HMAC-SHA256 over `` `${timestamp}.${rawBody}` `` in hex. Including the timestamp is what
makes a captured payload non-replayable.

```js
import { createHmac, timingSafeEqual } from 'crypto';

function verify(rawBody, headers, secret) {
  const ts = Number(headers['x-callcloud-timestamp']);
  // Reject anything older than five minutes, or a stolen payload replays forever.
  if (!ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(headers['x-callcloud-signature'] || '', 'utf8');
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Verify against the **raw body**, before any JSON parsing. Re-serialising changes the bytes and every
signature fails.

### Delivery behaviour

Three attempts, retrying only `429` and `5xx`: a `4xx` means your endpoint rejected the payload and
will reject it again. Respond `2xx` quickly and do your work asynchronously; each attempt times out
after 10 seconds.

`GET /api/agent-dialer/webhook` returns delivery health (`last_status`, `last_error`,
`consecutive_failures`). **After 20 consecutive failures the hook disables itself** rather than
hammering a dead URL on every call. Saving the config again re-enables it and clears the count.
Check this first when events stop arriving.

---

## 6. Building the browser leg

This is the part most integrations get wrong, so it is spelled out.

### Backend: mint a token

```http
POST /api/agent-dialer/browser-token
Authorization: Bearer cak_…
```

```json
{
  "session_id": "…",
  "sw_token": "…",        // WebRTC client credential
  "browser_token": "cbt_…",
  "dial_number": "+1…",   // the number the browser dials to become a controllable leg
  "pin": "123456",        // DTMF digits that bind this leg to the session
  "expires_at": "2026-07-29T…"
}
```

Expose this behind your own auth. It is the one call your frontend cannot make directly, because it
requires the `cak_` key.

### Frontend: go online

The mechanism, in case you are not using the reference hook: connect a SignalWire WebRTC client
with `sw_token`, dial `dial_number`, and send `pin` as DTMF once the call is up. That turns the tab
into a controllable leg the engine can conference people into. Then poll:

```http
GET /api/agent-dialer/browser-session
Authorization: Bearer cbt_…
```

This does two jobs at once: it **heartbeats** (the engine treats a silent leg as gone) and returns
both the leg's state and the live call, if any:

```json
{
  "status": "ready",
  "online": true,
  "current": { "number": "+1…", "name": null, "run_id": "…", "result_id": "…" }
}
```

`online` is `status === "ready"`, i.e. the DTMF bind completed. Drive your "you are live" indicator
off this rather than off your own local state, because the bind can fail after the WebRTC call
connects and only the server knows. `current` is null when nobody is on the line.

Poll every ~2 seconds while online. Missing heartbeats is what makes a leg look offline and causes
humans to be dropped.

### Going offline

```http
POST /api/agent-dialer/browser-session
Authorization: Bearer cbt_…
```

Marks the session disconnected. Call it on unmount, on tab close, and on an explicit "go offline"
control. A leaked leg keeps billing and keeps looking available to the engine.

There is a working reference implementation in the repo:
`app/components/useAgentBrowserLine.ts`, a React hook exposing
`{ online, connecting, error, call, goOnline, goOffline }`. A live build is at `/mcp/console`.

### UI requirements that come from the mechanics

- **Show online state prominently.** Offline means screened-in humans get hung up on. This is the
  single most damaging silent failure in the product.
- **Call `goOffline` on unmount and on tab close.** A leaked leg keeps billing.
- **Render `call` the instant it appears.** The person is already talking. There is no ringing
  state on your side, which is the whole point of the product, so a UI that animates a "connecting"
  spinner is showing a fiction and wasting the advantage.
- **Ask for microphone permission before going online**, not at first call. Browsers prompt
  asynchronously and a prompt mid-conversation loses the first seconds.

---

## 6b. Numbers

Agents provision their own numbers. Free while the included allotment lasts, then **$1/month
each**.

```http
GET  /api/agent-dialer/numbers                      # owned numbers + allotment left
GET  /api/agent-dialer/numbers/search?area_code=415 # carrier inventory, reserves nothing
POST /api/agent-dialer/numbers  { "area_code": "415" }   # or { "number": "+14155551234" }
```

`GET` returns each number with `included` (came from the allotment, so free), `status`
(reputation), and `daily_dial_cap`, plus:

```json
{ "allowance": { "included_total": 3, "included_used": 1, "included_remaining": 2 } }
```

`POST` returns `201` with `{ number, included, monthly_cents, allowance }`. `monthly_cents` is `0`
while the allotment covers it and `100` once it is a paid number.

Failure cases worth distinct handling:

| Status | Error | Meaning |
|---|---|---|
| `402` | `no_card` | Allotment used and no card on file. A paid number is a recurring charge created with nobody at a checkout page, so a card must exist first. Send the user to `/mcp` to add one. |
| `409` | `no_inventory` | That area code is dry. Suggest a nearby one. |
| `502` | `provision_failed` | The carrier refused. Billing is unwound automatically, so retrying is safe. |

### Releasing

```http
DELETE /api/agent-dialer/numbers?number=%2B14155551234
```

Returns `{ released, billing_stopped, allowance }`. `billing_stopped` is true only when the number
was on the agent subscription, so you can tell the user whether their bill actually changed.

**Irreversible.** The number goes back to the carrier and cannot be reclaimed. Two refusals to
handle:

| Status | Error | Meaning |
|---|---|---|
| `409` | `last_number` | It is the only number on the workspace. Releasing it would stop all dialing, so the API refuses. Provision a replacement first. |
| `502` | `release_failed` | The carrier refused. Most often because it is within ~14 days of purchase; the message says so. Retry later. |

The database row is deactivated rather than deleted, so past calls keep their caller-ID attribution.
The number simply stops appearing in `list_numbers`.

Billing is established **before** the carrier purchase and reversed if the purchase fails, so you
can never end up paying for a number you do not have. Numbers bill as one subscription with
quantity equal to how many you hold; releasing the last one cancels it.

**UI guidance.** A workspace with zero numbers cannot dial at all (`dial` returns
`no caller number available`). If you are building onboarding, check `GET /numbers` first and offer
area-code selection before anything else. Let the user choose the area code: local presence is the
entire reason the choice exists, and picking for them wastes it.

---

## 7. Credits and auto top-up

- $30 per 1,000 dials. No seat, no subscription. Credits do not expire or reset.
- One credit per number handed to the carrier. Numbers that never reach the network are not charged,
  and a dial that fails before placement is refunded.
- A run that exhausts the balance **stops at the credit that ran out** rather than overshooting by
  however many lines are in flight.
- `run_status.credits_remaining` is the cheapest place to read the balance during a run.

Auto top-up (optional, configured in the dashboard at `/mcp`) buys more when the balance falls below
a threshold, charging a saved card off-session. It disarms itself after 3 consecutive declines and
emails the owner. If you build your own billing UI, surface `failures > 0` prominently: a silently
disarmed auto top-up looks fine right up until dialing stops.

---

## 7b. Usage and spend, in one call

```http
GET /api/agent-dialer/usage?days=30
Authorization: Bearer cak_…
```

Built to render a dashboard without further round trips, or to answer "what have I spent" directly.

```json
{
  "credits":   { "remaining": 812, "remaining_value_cents": 2436, "cents_per_dial": 3, "pack_size": 1000, "pack_cents": 3000 },
  "auto_topup":{ "enabled": true, "threshold": 100, "packs": 1, "failures": 0, "last_error": null, "last_topped_up_at": "…" },
  "dials":     { "today": 40, "last_7_days": 260, "period": 1200, "all_time": 4300 },
  "outcomes":  { "humans": 310, "machines": 700, "other": 190, "talk_minutes": 420, "human_rate": 0.258 },
  "spend":     { "purchased_cents": 9000, "purchased_dials": 3000,
                 "consumed_period_cents": 3600, "consumed_all_time_cents": 12900,
                 "purchases": [ { "at": "…", "dials": 1000, "cents": 3000, "reference": "…" } ] },
  "daily":     [ { "date": "2026-07-01", "dials": 40, "humans": 11, "machines": 22, "spend_cents": 120 } ],
  "recent_runs": [ { "run_id": "…", "status": "done", "screening": "gate", "total": 40, "connected": 6, "error": null } ]
}
```

Notes that matter for rendering it:

- **All money is integer cents.** Summing floating-point dollars loses accuracy, and this is the
  number a customer reconciles against their card statement.
- **`spend.purchased_*` and `spend.consumed_*` are different things** and will not match. Purchased
  is real money that hit the card; consumed is the value of dials used. Prepaid credits mean the two
  move independently, so label them distinctly or you will field support tickets about it.
- **`daily` is zero-filled** across the whole window, so a chart has no gaps to interpolate.
- **`human_rate` is 0, never NaN**, when nothing was dialed.
- **`auto_topup.failures > 0` deserves prominence.** A silently disarmed auto top-up looks healthy
  right up until dialing stops.
- Only dials that actually reached the carrier are counted, which is exactly the set that consumed a
  credit, so these totals reconcile against the balance.

---

## 7c. Analytics: performance, not spend

`/usage` answers "what did I spend". This answers "how do I get more conversations".

```http
GET /api/agent-dialer/analytics?days=30
```

```json
{
  "totals":       { "dials": 1200, "humans": 310, "human_rate": 0.258, "talk_minutes": 420, "avg_talk_seconds": 81 },
  "by_caller_id": [ { "number": "+1…", "dials": 800, "humans": 60, "human_rate": 0.075, "talk_minutes": 90 } ],
  "by_hour_utc":  [ { "hour": 14, "dials": 96, "humans": 31, "human_rate": 0.32 } ],
  "by_screening": [ { "mode": "gate", "dials": 700, "humans": 190, "human_rate": 0.271 } ],
  "human_ring_ms": { "p10": 2100, "p50": 4800, "p90": 9200, "samples": 310 }
}
```

Three of these are worth building UI around:

**`by_caller_id`** is the one nobody else exposes. Number reputation is where outbound performance
quietly lives, and a number at 4% next to one at 22% is immediately actionable: rest it, rotate it,
or replace it.

**`by_hour_utc`** tells you when your specific list answers, which is rarely when you assume.

**`human_ring_ms`** is the evidence for tuning `gate_ring_seconds`. If `p10` sits below your
threshold you are hanging up on real people. A 6-second default was caught this way after it gated
a human who answered at 1.6s.

---

## 7d. Web-dialer data: lists, contacts, campaigns, analytics, companies

The same `cak_` key also manages the CallCloud **web dialer** - the rep-driven parallel dialer that
lives at dialer.callcloud.app. Everything except dialing: an agent can build the data and read the
results, and a rep presses Start in the browser. There is deliberately no API start/stop of web
campaigns, because parallel dialing needs a rep's browser connected to answer calls - an API
"start" could only ring people with nobody on the line.

**Lists and contacts.** `POST /lists` creates a list (optionally with up to 1,000 contacts inline);
`POST /contacts` adds more to it. Import semantics are identical to a CSV upload in the web app:
numbers are normalized to E.164, toll-free numbers dropped, and a contact whose number already
exists in the workspace is **linked** into the list rather than duplicated. Always read the
returned `import` breakdown - `{created, linked_existing, dropped_no_phone, dropped_toll_free,
dropped_duplicate}` explains any gap between what you sent and what became dialable, and the same
breakdown shows in the web app's list view.

```http
POST /api/agent-dialer/lists
{ "name": "Q4 outbound", "contacts": [
  { "first_name": "Jane", "last_name": "Doe", "company": "Acme Inc", "title": "VP Ops",
    "phone": "(415) 555-0134" }
] }
```

`GET /contacts?q=&company=&list_id=` searches prospects with the web search box's engine;
`GET /contacts/{id}` returns one in full including the 10 most recent calls;
`PATCH /contacts/{id}` updates fields (`do_not_call: true` suppresses the prospect from ALL
dialing, web and agent runs alike).

**Campaigns.** `POST /campaigns {name, list_ids, caller_id?, parallel?}` creates a **draft** web
campaign with lists attached - it appears on the web Home page ready for a rep to press Start.
`GET /campaigns` lists them with live queue stats (`prospects / dialed / removed / do_not_call /
remaining`), which is how a backend watches a calling block progress without touching it.

**Analytics.** `GET /web-analytics?days=30` is the web Analytics page over the API: totals
(`dials, connects, connect_rate_pct, talk_time_sec, meetings_booked`), a zero-filled `by_day`
series ready to chart, `by_rep` and `by_campaign` breakdowns, and the outcome/disposition mix.
Distinct from `/analytics` (this API's own runs) and `/usage` (spend).

**Companies.** `GET /companies` is the account view: contacts grouped by normalized company name
("Acme", "Acme Inc" and "ACME, LLC" are one account), each with its most recent booked meeting.
`GET /companies/{company}` accepts a raw name or the returned key. `POST /companies/{company}
{"action":"remove"}` pulls the whole account out of every active campaign's queue (reversible with
`"restore"`; call history untouched). CallCloud does the remove automatically when a rep logs a
booked-meeting outcome, so agents mostly need `restore` - for the account that re-engages.

---

## 8. Worked example

```js
// 1. Backend: get the frontend online.
const tok = await fetch(`${BASE}/api/agent-dialer/browser-token`, {
  method: 'POST', headers: { Authorization: `Bearer ${process.env.CALLCLOUD_API_KEY}` },
}).then((r) => r.json());
// hand tok.sw_token / tok.browser_token / tok.dial_number / tok.pin to your frontend

// 2. Start a run, instant-connect mode.
const run = await fetch(`${BASE}/api/agent-dialer/dial`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ numbers, screening: 'gate' }),
}).then((r) => r.json());
if (run.error) throw new Error(run.error);   // 402 => out of credits, show the buy action

// 3. Poll until it finishes.
let status;
do {
  await new Promise((r) => setTimeout(r, 2500));
  status = await fetch(`${BASE}/api/agent-dialer/runs/${run.run_id}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  }).then((r) => r.json());
} while (status.status === 'dialing');

// 4. Read what happened.
const { results } = await fetch(
  `${BASE}/api/agent-dialer/runs/${run.run_id}/results?filter=humans`,
  { headers: { Authorization: `Bearer ${KEY}` } },
).then((r) => r.json());
```

---

## 9. Gotchas

- **`answered_by: "unknown"` is a connected human**, not a failure. Fail-open is deliberate.
- **`connect_to` only accepts `"browser"`.** A phone number is a 400. Ringing a phone after the
  prospect answers would make them wait through your ring time.
- **Recording links expire in 15 minutes.** Mint on render.
- **Machines are never recorded**, so a machine result has no transcript. That is correct, not missing data.
- **Transcripts arrive after the call ends**, once transcription completes. A result can be `done`
  with `transcript: null` for a short window; re-read it rather than treating null as final.
- **Calling is gated on account approval** for this product exactly as for the human dialer. A
  `pending` or `suspended` workspace gets 403 no matter how many credits it holds.
