# Ringmaster Engine API Documentation

# Introduction

**Author**: Josh S. Sakweli, Backend Lead Team **Last Updated**: 2026-11-13 **Version**: v1.0

**Base URL**: `https://<ringmaster-host>/v1` (local: `http://localhost:8085/v1`)

**Short Description**: Ringmaster is an **engine for intelligent call centres**. Services build their calling features on it (they are its *client apps*): the engine does the telephony, the call logic, the AI and the agents; the service owns its users, its screens and its business rules. A business on such a service (an *account*) gets phone numbers; each number plays a *call flow* (a script of blocks: greet, menu, collect a number, voicemail, AI agent, connect to a person…). Ringmaster also calls people (reminders, campaigns, callbacks, OTP codes), rings the business's agents in their browser, records and transcribes calls, and tells the client app what happened through signed webhooks. This book is for developers of a **client app**: every endpoint, every event the engine sends, every value.

**Hints**:

- Every `/v1` request carries the client app's API key: `Authorization: Bearer <key>`. Keep it on your server; never ship it to a browser or phone.
- Ringmaster knows nothing about the client app's users, contacts, money or messages. It stores what it needs to ring and play: account settings, numbers, call flows, agents (a ref and a name), calls and their reports.
- Phone numbers are always E.164 (`+255745051250`). Times in responses are UTC ISO 8601 (`2026-11-13T14:41:00Z`); times *inside* an account (opening hours, quiet hours, "call me at 9") are in the account's time zone (default `Africa/Dar_es_Salaam`).
- Refusals you should handle in code come with a stable `code` (`SCRIPT_IN_USE`, `VERSION_NOT_RUNNABLE`…); switch on the code, show the message. Full list in [Part 4](4-reference.md#error-codes).
- How a call flow is written (all blocks, their settings and exits) is in [Part 5](5-flow-reference.md); how to build the editor, calls, reports, live screens and the screens around Agent Phone on top of the API is in [Part 6](6-screens.md).
- A working sample client to try everything by hand: the demo console (`console/`, `python3 console/serve.py`); its "API calls" bar shows the exact request for each click.

---

## Contents

<table id="bkmrk-page-what%27s-in-it-pa"><thead><tr><th>Page</th><th>What's in it</th></tr></thead><tbody><tr><td>[Part 1 — How Ringmaster works](1-how-it-works.md)</td><td>Read first: the objects, a call step by step, call-outs, flows and versions, agents, webhooks, app actions, recordings, costs, formats</td></tr><tr><td>[Part 2A — Setup: basics, accounts, numbers](2a-setup.md)</td><td>ping, script schema and check · accounts · numbers</td></tr><tr><td>[Part 2B — Call flows, media, knowledge](2b-flows.md)</td><td>scripts and versions · audio files · knowledge base for the AI</td></tr><tr><td>[Part 2C — Calls, call-outs, OTP, reports](2c-calls.md)</td><td>call history, timeline, recording, transcript · quick calls and call-outs · OTP · stats and exports</td></tr><tr><td>[Part 2D — Agents and live calls](2d-agents.md)</td><td>departments, agents, the browser desk · accept, hold, transfer, hang up</td></tr><tr><td>[Part 2E — App actions and opt-outs](2e-actions.md)</td><td>registering the client app's actions and answering them · the do-not-call list</td></tr><tr><td>[Part 2F — Agent Phone](2f-agent-phone.md)</td><td>connecting an agent's desktop app (pairing codes, devices, remove) · the app's own `/v1/agent` API</td></tr><tr><td>[Part 3 — What the engine sends you](3-webhooks.md)</td><td>webhooks: envelope, signature, retries, every event · agent desk signals</td></tr><tr><td>[Part 4 — Reference](4-reference.md)</td><td>every status and value · every error code · limits and defaults · glossary</td></tr><tr><td>[Part 5 — Call flow reference](5-flow-reference.md)</td><td>the flow document · messages · variables · all 18 blocks with every setting and exit · Waiting Room · AI Agent · check codes and fixes</td></tr><tr><td>[Part 6 — Building the screens](6-screens.md)</td><td>flow editor · calls page and call log · reports · agent desk and queue · which webhook updates what · the don't list · screens for Agent Phone (connect, devices, shared computers, shifts, settings, notices)</td></tr><tr><td>[Appendix — Not for client apps](appendix.md)</td><td>engine-internal routes · what the Ringmaster owner sets up with `ringmaster-admin`</td></tr></tbody></table>

---

## Standard Response Format

Every response uses the Globe envelope.

### Success

```json
{
  "success": true,
  "httpStatus": "OK",
  "message": "Script",
  "action": null,
  "action_time": "2026-11-13T17:44:40.645991261",
  "data": { }
}

```

### Error

```json
{
  "success": false,
  "httpStatus": "CONFLICT",
  "message": "The script is still in use: move these to another script first",
  "code": "SCRIPT_IN_USE",
  "action_time": "2026-11-13T18:09:38.582694597",
  "data": { "numbers": ["+255677000003"] }
}

```

<table id="bkmrk-field-type-descripti"><thead><tr><th>Field</th><th>Type</th><th>Description</th></tr></thead><tbody><tr><td>`success`</td><td>boolean</td><td>`true` for success, `false` for errors</td></tr><tr><td>`httpStatus`</td><td>string</td><td>HTTP status, e.g. `"200 OK"`, `"409 CONFLICT"`</td></tr><tr><td>`message`</td><td>string</td><td>Human-readable result; safe to show</td></tr><tr><td>`code`</td><td>string</td><td>Errors only: stable machine code to switch on (`NOT_FOUND`, `VALIDATION_FAILED`, `SCRIPT_IN_USE`…)</td></tr><tr><td>`action_time`</td><td>string</td><td>Server time of the response (local, no zone)</td></tr><tr><td>`data`</td><td>any</td><td>The payload; on errors, details (field messages, what is in the way) or `null`</td></tr></tbody></table>

<table id="bkmrk-http-code-when-400-b"><thead><tr><th>HTTP</th><th>`code`</th><th>When</th></tr></thead><tbody><tr><td>400</td><td>`BAD_REQUEST`</td><td>Body is not valid JSON, a query parameter is missing or not the right type, a value is not allowed</td></tr><tr><td>401</td><td>`UNAUTHORIZED`</td><td>Missing or wrong API key</td></tr><tr><td>403</td><td>`CLIENT_SUSPENDED`, `FEATURE_OFF`, `CAN_CALL_REFUSED`</td><td>The app is suspended, the feature is not enabled for it, or its can-call check said no</td></tr><tr><td>404</td><td>`NOT_FOUND`</td><td>No such object, or it belongs to another client app</td></tr><tr><td>409</td><td>a specific code</td><td>A rule says no (in use, wrong state, already answered); `data` says what is in the way</td></tr><tr><td>422</td><td>`VALIDATION_FAILED`</td><td>Field errors in `data` (`{"field": "message"}`), or a script's error list</td></tr><tr><td>429</td><td>`RATE_LIMITED`</td><td>More requests per second than the app's limit</td></tr><tr><td>500</td><td>`INTERNAL_ERROR`</td><td>A bug on our side; the message is generic</td></tr></tbody></table>

---

## HTTP Method Badge Standards

- **GET** - <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> - read only
- **POST** - <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> - create / act
- **PUT** - <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> - create or replace
- **PATCH** - <span style="background-color: #fd7e14; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> - change some fields
- **DELETE** - <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> - remove

---

## Every endpoint at a glance

<table id="bkmrk-group-method-path-pa"><thead><tr><th>Group</th><th>Method</th><th>Path</th><th>Page</th></tr></thead><tbody><tr><td>Basics</td><td>GET</td><td>`/v1/ping`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>GET</td><td>`/v1/scripts/schema`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/scripts/validate`</td><td>[2A](2a-setup.md)</td></tr><tr><td>Accounts</td><td>POST · GET</td><td>`/v1/accounts`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>GET · PATCH</td><td>`/v1/accounts/{accountId}`</td><td>[2A](2a-setup.md)</td></tr><tr><td>Numbers</td><td>GET · POST</td><td>`/v1/numbers`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>DELETE</td><td>`/v1/numbers/{numberId}`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/numbers/{numberId}/assign` · `/release` · `/transfer`</td><td>[2A](2a-setup.md)</td></tr><tr><td></td><td>PUT</td><td>`/v1/numbers/{numberId}/script` · `/ring-mode`</td><td>[2A](2a-setup.md)</td></tr><tr><td>Scripts</td><td>GET</td><td>`/v1/accounts/{accountId}/scripts`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>PUT · GET · DELETE</td><td>`/v1/accounts/{accountId}/scripts/{ref}`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/accounts/{accountId}/scripts/{ref}/versions`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>GET</td><td>`/v1/accounts/{accountId}/scripts/{ref}/versions/{version}`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>POST</td><td>`…/versions/{version}/runnable` · `…/versions/{version}/revoke`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>GET</td><td>`…/versions/{version}/stats`</td><td>[2B](2b-flows.md)</td></tr><tr><td>Media</td><td>POST · GET</td><td>`/v1/accounts/{accountId}/media`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>GET · DELETE</td><td>`/v1/media/{mediaId}`</td><td>[2B](2b-flows.md)</td></tr><tr><td>Knowledge</td><td>POST · GET</td><td>`/v1/accounts/{accountId}/kb-docs`</td><td>[2B](2b-flows.md)</td></tr><tr><td></td><td>DELETE</td><td>`/v1/accounts/{accountId}/kb-docs/{documentId}`</td><td>[2B](2b-flows.md)</td></tr><tr><td>Calls</td><td>GET</td><td>`/v1/calls` · `/v1/calls/{callId}` · `/timeline` · `/recording` · `/transcript`</td><td>[2C](2c-calls.md)</td></tr><tr><td></td><td>GET</td><td>`/v1/calls/export.csv`</td><td>[2C](2c-calls.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/calls` (quick call) · `/v1/calls/agent`</td><td>[2C](2c-calls.md)</td></tr><tr><td>Call-outs</td><td>POST · GET</td><td>`/v1/callouts`</td><td>[2C](2c-calls.md)</td></tr><tr><td></td><td>GET</td><td>`/v1/callouts/{id}` · `/recipients` · `/stats` · `/export.csv`</td><td>[2C](2c-calls.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/callouts/{id}/recipients` · `/v1/callouts/{id}/{action}`</td><td>[2C](2c-calls.md)</td></tr><tr><td>OTP</td><td>POST</td><td>`/v1/otp`</td><td>[2C](2c-calls.md)</td></tr><tr><td>Stats</td><td>GET</td><td>`/v1/accounts/{accountId}/stats`</td><td>[2C](2c-calls.md)</td></tr><tr><td>Agents</td><td>GET</td><td>`/v1/accounts/{accountId}/agents` · `/agents/stats`</td><td>[2D](2d-agents.md)</td></tr><tr><td></td><td>PUT · DELETE</td><td>`/v1/accounts/{accountId}/agents/{ref}`</td><td>[2D](2d-agents.md)</td></tr><tr><td></td><td>GET</td><td>`/v1/accounts/{accountId}/agents/{ref}/desk` · `/desk/search`</td><td>[2D](2d-agents.md#i14-the-agents-desk)</td></tr><tr><td>Agent calls</td><td>POST</td><td>`/v1/calls/{callId}/join` · `/call-back`</td><td>[2D](2d-agents.md#j7-join-an-outgoing-call)</td></tr><tr><td>Devices</td><td>POST · GET · DELETE</td><td>`/v1/accounts/{accountId}/agents/{ref}/devices[/pairings · /{deviceId}]`</td><td>[2F](2f-agent-phone.md)</td></tr><tr><td>Agent Phone</td><td>POST · GET · DELETE</td><td>`/v1/agent/pair` · `/token` · `/me` · `/search` · `/lobby-token` · `/calls…` · `/device`</td><td>[2F](2f-agent-phone.md#l-agent-phones-api-device-token)</td></tr><tr><td></td><td>PUT · POST · POST</td><td>`…/agents/{ref}/status` · `/ready` · `/lobby-token`</td><td>[2D](2d-agents.md)</td></tr><tr><td>Departments</td><td>POST · GET</td><td>`/v1/accounts/{accountId}/departments`</td><td>[2D](2d-agents.md)</td></tr><tr><td></td><td>PUT · DELETE</td><td>`…/departments/{departmentId}`</td><td>[2D](2d-agents.md)</td></tr><tr><td></td><td>PUT · GET</td><td>`…/departments/{departmentId}/members` · `/queue`</td><td>[2D](2d-agents.md)</td></tr><tr><td>Live calls</td><td>POST</td><td>`/v1/calls/{callId}/accept` · `/decline` · `/hold` · `/unhold` · `/transfer` · `/hangup`</td><td>[2D](2d-agents.md)</td></tr><tr><td>App actions</td><td>PUT · GET</td><td>`/v1/actions`</td><td>[2E](2e-actions.md)</td></tr><tr><td></td><td>POST</td><td>`/v1/calls/{callId}/action-result`</td><td>[2E](2e-actions.md)</td></tr><tr><td>Opt-outs</td><td>POST · GET</td><td>`/v1/accounts/{accountId}/opt-outs`</td><td>[2E](2e-actions.md)</td></tr><tr><td></td><td>DELETE</td><td>`/v1/accounts/{accountId}/opt-outs/{e164}`</td><td>[2E](2e-actions.md)</td></tr></tbody></table>

# Part 1 — How Ringmaster works

[← Contents](README.md) · Next: [Part 2A — Setup](2a-setup.md)

Read this page before the endpoints. Ringmaster is the **engine** of an intelligent call centre; your service is a **client app** built on it. This page explains the engine's objects, how a call runs, and what the client app must do at each step.

---

## 1.1 Who's who

<table id="bkmrk-who-what-they-are-ex"><thead><tr><th>Who</th><th>What they are</th><th>Example</th></tr></thead><tbody><tr><td>**Ringmaster owner**</td><td>Runs the engine; creates client apps, buys phone numbers, gives numbers to client apps (`ringmaster-admin`, see [Appendix](appendix.md))</td><td>QBIT SPARK ops</td></tr><tr><td>**Client app**</td><td>A product that uses the engine through this API. Has one API key, a webhook URL and a set of features</td><td>a messaging or business platform</td></tr><tr><td>**Account**</td><td>One business inside a client app. Everything (numbers, flows, calls, agents) belongs to an account</td><td>PMK Chemicals, Kliniki ya Afya Bora</td></tr><tr><td>**Number**</td><td>A phone number the account answers on and calls from</td><td>`+255677000010`</td></tr><tr><td>**Call flow (script)**</td><td>What a caller hears and can do, as wired blocks. Stored in numbered, unchangeable **versions**</td><td>`orders` v3</td></tr><tr><td>**Agent**</td><td>A person who answers calls in the browser (or on a mobile). Ringmaster keeps only a ref and a name</td><td>`asha`</td></tr><tr><td>**Department**</td><td>A group of agents a flow can send callers to</td><td>Mapokezi, Uhasibu</td></tr><tr><td>**Caller / person called**</td><td>Anyone on a phone. Ringmaster keeps only the number and what happened on the call</td><td>`+255745051250`</td></tr></tbody></table>

```
Ringmaster owner
 └── Client app  (API key, webhook URL, features)
      ├── Account: PMK Chemicals  (hours, quiet hours, recording, limits)
      │    ├── Numbers ── each plays one pinned flow version
      │    ├── Call flows ── versions v1, v2, v3 …
      │    ├── Departments ── agents
      │    ├── Media (audio files), knowledge base (for the AI), opt-outs
      │    └── Calls, call-outs, reports
      └── Account: Kliniki ya Afya Bora …

```

A client app maps its own objects to these: one of its businesses = an account (`externalRef` = its business id), one of its users who answers calls = an agent (`ref` = its user id).

---

## 1.2 Getting access

The Ringmaster owner creates the client app once and gives it:

<table id="bkmrk-item-what-it-is-wher"><thead><tr><th>Item</th><th>What it is</th><th>Where it's used</th></tr></thead><tbody><tr><td>**API key**</td><td>`vk_live_…`, shown once</td><td>Every request: `Authorization: Bearer <key>`</td></tr><tr><td>**Webhook URL**</td><td>The client app's HTTPS endpoint, e.g. `https://app.example.co.tz/hooks/ringmaster`</td><td>The engine POSTs events there ([Part 3](3-webhooks.md))</td></tr><tr><td>**Webhook secret**</td><td>Shown once; signs every webhook</td><td>The client app checks each event's signature</td></tr><tr><td>**Features**</td><td>What the app may do</td><td>See below</td></tr><tr><td>**Rate limit**</td><td>Requests per second for the app (fixed one-second window)</td><td>Over it → `429 RATE_LIMITED`</td></tr></tbody></table>

<table id="bkmrk-feature-allows-inbou"><thead><tr><th>Feature</th><th>Allows</th></tr></thead><tbody><tr><td>`INBOUND`</td><td>Answering incoming calls. Without it every incoming call is refused (`REJECTED`, `FEATURE_OFF`)</td></tr><tr><td>`CALLOUTS`</td><td>Calling people: quick calls and call-outs (`403 FEATURE_OFF` without it); callers booking a callback (refused without it)</td></tr><tr><td>`OTP`</td><td>Voice OTP calls (`POST /v1/otp`); without it → `403 FEATURE_OFF`</td></tr><tr><td>`AI`</td><td>AI Agent blocks and "smart" (AI) speech matching in Menu, Poll and Pick; a flow using them is refused (`403 FEATURE_OFF`)</td></tr><tr><td>`NUMBERS`</td><td>Adding and removing numbers through the API; without it → `403 FEATURE_OFF`</td></tr></tbody></table>

Features are checked when something is made **and again when it runs**: if the owner takes a feature away later, running call-outs pause (`FEATURE_OFF`), AI Agent blocks take their `err` exit, and smart speech matching falls back to the options' own words. Agent click-to-call (`POST /v1/calls/agent`) needs no feature.

A client app only ever sees its own accounts, numbers and calls. Another app's id gives `404 NOT_FOUND`.

---

## 1.3 Setting up a business, in order

```
1. POST  /v1/accounts                         {externalRef: "pmk-chemicals"}        → accountId
2. PATCH /v1/accounts/{accountId}             {businessName, hours, quietHours, …}
3. GET   /v1/numbers?status=ALLOCATED          free numbers the owner gave the client app
   POST  /v1/numbers/{numberId}/assign         {accountId, label}
4. PUT   /v1/accounts/{accountId}/scripts/orders            {name, direction: "INCOMING"}
   POST  /v1/accounts/{accountId}/scripts/orders/versions   {version: 1, definition, runnable: true}  → versionId
5. PUT   /v1/numbers/{numberId}/script         {scriptVersionId}

```

From step 5 on, calls to the number play the flow. Optional: departments and agents (to connect callers to people), media (recorded greetings, music), knowledge base (for AI agents), App actions (SMS, payments done by the client app).

---

## 1.4 An incoming call, step by step

```
Caller dials +255677000010
   │  phone network → SIP trunk → LiveKit room → voice worker joins
   ▼
Engine: which account and which flow version is pinned on the number?   (none → call refused, NO_SCRIPT)
   │  checks: app active, account active, INBOUND on, caller under the per-caller limit
   ▼
call.started webhook ──────────────────────────────────────────► client app
   │
   ▼  blocks run one after another, starting at the flow's start block
   say "Karibu PMK Chemicals…" → menu "Bonyeza 1 kuagiza…" → (caller presses 1 or says "oda")
   │      every step is written to the call's timeline (BLOCK_ENTER, SAY, KEYS, SPEECH, …)
   │      call.block webhooks for the steps clients care about (menu choice, App action, …)
   ▼
   … collect → pick → confirm → goodbye
   ▼
Call ends ─► call.ended webhook (full summary: result, durations, keys, outcomes, cost)
         └─► recording.ready / summary.ready later, if anything was recorded

```

What the caller hears is played **in order, block after block**: a block's sound finishes before the next block's sound starts. Blocks that make something happen for a *person* wait for that too: a Connect block rings agents only after the caller has heard everything before the waiting music.

**Statuses of a call** (`status`): `QUEUED` → `DIALING` → `RINGING` (outgoing only) → `IN_SCRIPT` → `WAITING`(waiting room) → `CONNECTING` → `LIVE` (with an agent) → `ENDED`. After it ends, `result` says how it went (`HANDLED_BY_SCRIPT`, `HANDLED_BY_AGENT`, `HANDLED_BY_AI`, `VOICEMAIL`, `MISSED`, `NO_ANSWER`, `BUSY`, `DECLINED`…), `endReason` why it ended and `endedBy` who ended it. All values: [Part 4](4-reference.md#calls).

**Guards that protect every account** (account `limits`, changeable with `PATCH /v1/accounts/{id}`):

<table id="bkmrk-limit-default-what-h"><thead><tr><th>Limit</th><th>Default</th><th>What happens</th></tr></thead><tbody><tr><td>`maxCallMinutes`</td><td>15</td><td>The call is ended (`MAX_CALL_LENGTH`)</td></tr><tr><td>`maxWaitSeconds`</td><td>300</td><td>Longest wait in a waiting room, whatever the flow says</td></tr><tr><td>`callerCallsPer10Min`</td><td>5</td><td>The same caller calling more often is refused (`REJECTED`, `CALLER_LIMIT`)</td></tr><tr><td>`otpPerDestinationPer10Min`</td><td>3</td><td>More OTP calls to one number → refused</td></tr><tr><td>`voicemailMaxSeconds`</td><td>120</td><td>Longest voicemail</td></tr><tr><td>`maxBlockVisits`</td><td>25</td><td>Blocks run without any input from the caller before the call is ended (`LOOP_GUARD`) — stops endless loops</td></tr></tbody></table>

---

## 1.5 Calling people (call-outs)

The engine places calls for an account and plays an **Outgoing** flow when the person answers.

<table id="bkmrk-way-endpoint-use-qui"><thead><tr><th>Way</th><th>Endpoint</th><th>Use</th></tr></thead><tbody><tr><td>**Quick call**</td><td>`POST /v1/calls`</td><td>"Your order is ready" to 1–1000 people, now. Created and started in one request</td></tr><tr><td>**Broadcast**</td><td>`POST /v1/callouts` `kind: BROADCAST`</td><td>The same, but built in steps (create → add people → start), optionally at `startsAt`</td></tr><tr><td>**Campaign**</td><td>`POST /v1/callouts` `kind: CAMPAIGN`</td><td>Calling a list over days: a daily window (`windowFrom`/`windowTo`), week days, pace per minute, up to 5 tries, a fallback App action (e.g. SMS) after the last failed try</td></tr><tr><td>**Scheduled callback**</td><td>made by a caller during a call (Schedule call block or AI `schedule_call`)</td><td>"Nipigie baada ya saa moja": a one-person call-out with ref `schedule:<flow>:<number>`; the client app sees it in `GET /v1/callouts` and the `call.scheduled` webhook</td></tr><tr><td>**OTP**</td><td>`POST /v1/otp`</td><td>Read a code to a number</td></tr></tbody></table>

Each person on a call-out is a **recipient** with their own `vars` (e.g. `{"name": "Asha", "amount": "45000"}`) that the flow says as `{name}`, `{amount}`.

**Rules the engine applies to every call-out** (the client app doesn't need to):

- **Quiet hours** (account `quietHours`, default 21:00–07:00): nobody is called inside them; calls wait for the end.
- **Opt-outs**: a number on the account's opt-out list is skipped (`SKIPPED`), checked again just before dialling.
- **Retries**: busy, no answer, unreachable or failed → tried again after `retryGapSec` (≥ 300 s) up to `maxTries`(quick call ≤ 3, campaign ≤ 5). **Declined** (the person rejected the call) is never retried.
- **Pace**: campaigns dial at most `pacePerMin`.

Call-out statuses: `DRAFT` → `SCHEDULED` → `RUNNING` ⇄ `PAUSED` → `COMPLETED` / `STOPPED` / `CANCELLED`. Recipient: `PENDING` → `DIALING` → `DONE` (with a `result`) or `SKIPPED`.

---

## 1.6 A call flow's life

```
PUT  …/scripts/{ref}                    create the flow (name, INCOMING or OUTGOING); direction never changes
POST …/scripts/{ref}/versions           push version 1, 2, 3 … — each version is frozen once stored
         │  the engine checks it (Part 5 §5.9): errors → 422 with the list, nothing stored
         │                                       warnings → stored, warnings returned
         ▼
      runnable?  ── no (you pushed runnable:false: it has risky blocks and waits for approval)
         │              POST …/versions/{n}/runnable  after the client app's approval step
         ▼ yes
PUT  /v1/numbers/{id}/script            put the version on a number (Incoming)     — or —
POST /v1/calls · /v1/callouts           use it for calls out (Outgoing)
POST …/versions/{n}/revoke              stop a version for good: a number still playing it refuses calls,
                                        call-outs using it pause (VERSION_REVOKED) — move them first
DELETE …/scripts/{ref}                  delete the flow (refused while anything uses it; archived if it ever ran)

```

- **Versions never change.** To change a flow, push the next version and move the number to it. Calls keep pointing at the exact version they ran, so reports stay true.
- **Pushing the same version number with the same document again** is safe: `200 "Version already stored"`.
- **Approval is the client app's policy.** Some blocks are risky: `collect` (callers' digits), `request` (calls a URL), `ai_agent`, and App actions the client app registered as risky (e.g. pay). The engine lists them in `riskyBlocks`when a version is checked or stored, and trusts the `runnable` flag it is given: push such a version with `runnable: false`, let the business's reviewer approve it in the client app, then `POST …/versions/{n}/runnable`. A not-runnable version can't be put on a number. A call-out may already use it: it is saved and waits (`waitingApproval: true`), then starts by itself once the version is approved.
- **`testOnly` versions** are for test calls only and never become runnable.
- **Reading a flow back**: `GET …/scripts/{ref}/versions/{n}` returns the full document. Secrets stored in Send request blocks come back as `"(hidden)"`.
- **Layout** (block positions on the client app's canvas) belongs to the client app: the engine drops a `layout` key on push.
- **Texts are turned into speech when the version is stored**, so the first caller doesn't wait.

---

## 1.7 Agents in the browser

```
Client app (once): POST departments {name, strategy, ringTimeoutSec…}  ·  PUT agents/{ref} {displayName}
                 PUT departments/{id}/members {agentRefs: [...]}      (list order = round-robin order)

Agent's browser:
  "Start shift"  → client app backend → POST agents/{ref}/lobby-token → {url, room, token}
                 → browser joins the lobby room (LiveKit JS SDK, data only, no mic)
                 → client app backend → PUT agents/{ref}/status {AVAILABLE}
  A caller reaches a Connect block → hears "tunakuunganisha…", then music
                 → engine sends {type:"ring", callId, from, department} into the lobby
  "Accept"       → client app backend → POST /v1/calls/{callId}/accept {agentRef} → {url, room, token}
                 → browser joins the call room with the microphone on → they talk
  "Hold" / "Transfer" / "Hang up" → POST /v1/calls/{callId}/hold · /transfer · /hangup {agentRef}
  Call ends      → agent is WRAP_UP for the department's wrapUpSec, then AVAILABLE (or POST …/ready)

```

**Agent statuses**: `OFFLINE` → `AVAILABLE` → `RINGING` → `BUSY` → `WRAP_UP` → `AVAILABLE`; `AWAY` = on a break (online, no calls). One call at a time per agent. Missing `maxMissedBeforeAway` rings in a row (default 5) moves the agent to `AWAY`. Every change is sent as an `agent.status` webhook (for a wallboard).

**Ring modes** (per number, `PUT /v1/numbers/{id}/ring-mode`): `BROWSER` (agents' browsers), `MOBILE` (agents' mobiles), `BROWSER_AND_MOBILE`.

The browser needs HTTPS (or `localhost`) for the microphone, and the "Start shift" click is what allows the ring sound. Details of every signal: [Part 3](3-webhooks.md#33-agent-desk-signals).

---

## 1.8 Webhooks

Every event is a signed `POST` to the client app's webhook URL:

```
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: 5b0c7f8e-…            (same as the body's id; use it to drop repeats)
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 9f2c…                 hex HMAC-SHA256(secret, "<timestamp>.<raw body>")

{ "id": "5b0c7f8e-…", "type": "call.ended", "createdAt": "2026-11-13T16:44:04Z", "data": { … } }

```

- Answer **2xx quickly** (do the work after). Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then every 12 h until 24 h have passed; then the event is marked failed.
- Events are written in the same database step as the change they describe, so none is lost; a repeat can arrive — drop it by `id`.
- Check the signature on the **raw body** before parsing, and refuse timestamps more than 5 minutes old.

All events and their data: [Part 3](3-webhooks.md).

---

## 1.9 App actions (the client app does something during a call)

Ringmaster never sends SMS, takes payments or tags contacts itself. The client app **registers** what it can do; a flow's **App action** block asks for it; the client app does it and answers.

```
PUT /v1/actions  [ {name: "pay", label: "Pay now", params: {...}, wait: true, outcomes: ["paid","failed"], timeoutSec: 120}, … ]

Caller in a flow reaches  "pay_1": {type: "app_action", action: "pay", params: {amount: "{amount}"}, x: {paid: …, failed: …, timeout: …}}
   engine ─► webhook action.requested {actionId, name: "pay", callId, params: {amount: "45000", phone: "+2557…"}, wait: true, timeoutAt}
   caller waits (music, "Thibitisha malipo kwenye simu yako")
   client app pushes M-Pesa … PIN entered …
   client app ─► POST /v1/calls/{callId}/action-result {actionId, outcome: "paid", data: {receipt: "QK7…"}}
   engine: the flow goes on through the "paid" exit; {action.pay_1.receipt} can be said

```

`wait: false` actions (send SMS, tag a contact) are fired and the call goes on at once.

---

## 1.10 Recordings, transcripts, summaries

<table id="bkmrk-recorded-when-voicem"><thead><tr><th>Recorded</th><th>When</th></tr></thead><tbody><tr><td>**Voicemail**</td><td>Always, when a flow reaches a Voicemail block (up to `voicemailMaxSeconds`)</td></tr><tr><td>**Live parts**</td><td>Talk with an agent, when the account's `recording.mode` is `LIVE_PARTS` (default) and the department / block allows it. Callers hear the `noticeText` first</td></tr><tr><td>**Callers' spoken answers**</td><td>Short clips used for speech recognition; not kept as recordings</td></tr></tbody></table>

After a recording: `recording.ready` webhook → `GET /v1/calls/{id}/recording` (MP3). If `recording.transcribe` is on (default), a transcript and a short summary follow: `summary.ready` → `GET /v1/calls/{id}/transcript`. Recordings are encrypted at rest and deleted after the account's `retentionDays` (default 90).

---

## 1.11 Costs

Every call carries `providerCost`: what it cost Ringmaster in phone minutes and AI, line by line.

```json
"providerCost": { "tzs": 68.61, "usd": 0.000334, "items": [
  { "provider": "AT", "tzs": 68.61, "lines": [
      { "item": "AT_OUTBOUND", "qty": 1, "unit": "MINUTE", "rate": 60, "currency": "TZS", "amount": 60, "source": "RATE" },
      { "item": "AT_SIP_TRUNK", "qty": 1, "unit": "MINUTE", "rate": 8.61, "currency": "TZS", "amount": 8.61, "source": "RATE" } ] },
  { "provider": "OPENAI", "usd": 0.000334, "lines": [
      { "item": "OPENAI_TRANSCRIBE", "qty": 0.0742, "unit": "MINUTE", "rate": 0.0045, "currency": "USD", "amount": 0.0003339, "source": "MEASURED", "model": "gpt-transcribe" } ] } ] }

```

The client app decides what to charge its customers; these are inputs, not prices.

---

## 1.12 Formats

<table id="bkmrk-what-format-phone-nu"><thead><tr><th>What</th><th>Format</th></tr></thead><tbody><tr><td>Phone numbers</td><td>E.164 with `+`: `+255745051250`</td></tr><tr><td>IDs</td><td>UUID strings; your own ids go in `externalRef` (accounts), `ref` (scripts, agents)</td></tr><tr><td>`ref` / `externalRef`</td><td>Your text id, up to 120 characters; letters, digits, `-`, `_` recommended</td></tr><tr><td>Times in responses</td><td>UTC ISO 8601 (`2026-11-13T14:41:00.802627Z`)</td></tr><tr><td>Times inside an account</td><td>`HH:mm` in the account's `timezone` (opening hours, quiet hours, campaign windows)</td></tr><tr><td>Languages</td><td>`sw` (Kiswahili, default), `en`</td></tr><tr><td>Lists</td><td>`GET /v1/calls` pages with `page` (from 0) and `size` (default 50) → `{calls, page, size, total}`; other lists return everything</td></tr><tr><td>Money in costs</td><td>TZS with 2 decimals, USD with up to 7</td></tr></tbody></table>

# Part 2 — Setups, flows, calls, agents,

# Part 2A — Setup: basics, accounts, numbers

[← Contents](README.md) · Previous: [Part 1](1-how-it-works.md) · Next: [Part 2B — Call flows](2b-flows.md)

Every endpoint here needs `Authorization: Bearer <api key>` (🔒). The envelope and error codes are on the
[contents page](README.md#standard-response-format).

---

# A. Basics

## A1. Ping
**Purpose**: Check the key works and see which features the client app has.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/ping`

**Access Level**: 🔒 Protected · Bearer API key

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "pong",
  "action": null,
  "action_time": "2026-11-13T19:57:58.900619432",
  "data": {
    "clientAppId": "bd74035b-bdb6-450d-885b-222e83719906",
    "name": "softphone-test",
    "features": ["INBOUND", "CALLOUTS", "OTP", "AI"]
  }
}
```

| Field | Description |
|-------|-------------|
| `clientAppId` | The client app this key belongs to |
| `name` | Its name at the Ringmaster owner |
| `features` | What it may do: `INBOUND`, `CALLOUTS`, `OTP`, `AI`, `NUMBERS` ([Part 1 §1.2](1-how-it-works.md#12-getting-access)) |

**Error Response JSON Sample** (no or wrong key):
```json
{
  "success": false,
  "httpStatus": "401 UNAUTHORIZED",
  "message": "Missing or invalid API key",
  "code": "UNAUTHORIZED",
  "action_time": "2026-11-13T19:57:59.067583389",
  "data": null
}
```
Also: `403 CLIENT_SUSPENDED` (the owner suspended the app), `429 RATE_LIMITED`.

---

## A2. Script schema
**Purpose**: The block catalogue a flow editor needs: every block type, its group, what it does, its exits, whether
it needs approval; the rules; the message shape; the variables a flow can use; an example flow.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/scripts/schema`

**Access Level**: 🔒 Protected · Bearer API key

**Success Response JSON Sample** (shortened: 18 blocks in `blocks`):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script schema",
  "action": null,
  "action_time": "2026-11-13T19:58:20.112842101",
  "data": {
    "schemaVersion": "1",
    "blocks": [
      { "type": "say", "group": "Speak", "description": "Say a text or play an audio message, then continue.",
        "exits": ["next"], "needsApproval": false },
      { "type": "menu", "group": "Ask",
        "description": "Ask the caller to pick an option by key or, when speech is on, by voice …",
        "exits": ["<option>", "noInput", "notUnderstood", "later"], "needsApproval": false },
      { "type": "collect", "group": "Ask", "description": "Collect a number (account, PIN, amount) into {input.<name>}.",
        "exits": ["ok", "failed", "later"], "needsApproval": true }
    ],
    "rules": [
      "direction is INCOMING (start exit 'call') or OUTGOING (start exit 'out'); never both",
      "every block: {type, …settings, before?: message, wr?: waiting room, x: {exit: blockId}}; a missing exit ends the call",
      "at most 60 blocks, 10 menu keys, 3 retries; text at most 500 characters"
    ],
    "messageShape": {
      "text": { "src": "text", "text": "Habari {name}", "lang": "sw",
                "voice": "marin|coral|shimmer|sage|cedar|ash|echo|ballad|verse", "speed": "0.7-1.5 (1.0 normal)…" },
      "audio": { "src": "record|upload|library", "media": "<mediaId>" }
    },
    "systemVariables": ["date", "time", "lang", "answeredBy", "key", "heard", "number", "caller", "callId"],
    "example": {
      "direction": "INCOMING",
      "start": { "call": "greet" },
      "blocks": {
        "greet": { "type": "say", "msg": { "src": "text", "text": "Karibu!" }, "x": { "next": "menu" } },
        "menu": { "type": "menu", "msg": { "src": "text", "text": "Bonyeza moja kwa mauzo, mbili kwa msaada." },
                  "options": [ { "k": "1", "id": "sales", "label": "Mauzo", "words": ["mauzo", "kununua"] },
                               { "k": "2", "id": "help", "label": "Msaada", "words": ["msaada", "tatizo"] } ],
                  "x": { "sales": "sales", "help": "help", "noInput": "bye", "notUnderstood": "bye" } },
        "sales": { "type": "say", "msg": { "src": "text", "text": "Bei zetu ziko kwenye tovuti yetu." }, "x": { "next": "bye" } },
        "help": { "type": "say", "msg": { "src": "text", "text": "Mhudumu atakupigia leo." }, "x": { "next": "bye" } },
        "bye": { "type": "goodbye", "msg": { "src": "text", "text": "Asante, kwaheri." } }
      }
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `blocks[].exits` | Exit names. `<option>` = one exit per menu/connect option (its `id`); `<outcome>` = one per App action outcome; `<return>` = the names a transferred flow returns with |
| `blocks[].needsApproval` | A risky block: a version using it should be approved before it runs. The engine reports these in `riskyBlocks`; the client app pushes such versions with `runnable: false` and approves them later ([Part 2B, D7](2b-flows.md#d7-approve-a-version-make-it-runnable)) |
| `systemVariables` | Always available in texts as `{name}`: `{caller}`, `{date}`, `{time}`, `{lang}`, `{key}` (last key), `{heard}` (last speech), `{number}` (number called), `{callId}`, `{answeredBy}` (outgoing, with answering-machine detection: `person` / `machine`) |

The full meaning of every block setting is in [Part 5](5-flow-reference.md). The schema is generated from the engine's
code, so it always matches what the engine accepts; cache it per `schemaVersion`.

---

## A3. Check a script (dry run)
**Purpose**: Validate a flow document without storing it: the same checks as pushing a version. Use it in the editor
on every save.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/scripts/validate`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{
  "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
  "definition": {
    "direction": "INCOMING",
    "start": { "call": "a" },
    "blocks": {
      "a": { "type": "say", "msg": { "src": "text", "text": "Hi" }, "x": { "next": "zz" } }
    }
  }
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `definition` | object | Yes | The flow document ([Part 5](5-flow-reference.md)) |
| `accountId` | uuid | No | With it, references are checked too: departments, media, the flows a Transfer or Schedule block names |

**Success Response JSON Sample** (the check ran; this flow has an error):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Validation result",
  "action": null,
  "action_time": "2026-11-13T18:06:38.004985164",
  "data": {
    "valid": false,
    "errors": [
      { "level": "ERROR", "block": "a", "code": "EXIT_TARGET_MISSING", "message": "Exit 'next' points to a missing block: zz" }
    ],
    "warnings": [],
    "vars": [],
    "texts": ["Hi"],
    "riskyBlocks": []
  }
}
```

| Field | Description |
|-------|-------------|
| `valid` | `true` when there are no errors (warnings are allowed) |
| `errors[]` / `warnings[]` | `{level, block, code, message}`; codes in [Part 4](4-reference.md#script-check-codes). Typical warning: `EXIT_UNCONNECTED` (the call ends there) |
| `vars` | Variables the flow uses that a call-out must provide (`{name}`, `{amount}`) |
| `texts` | Every fixed sentence; the engine turns these into audio when a version is stored |
| `riskyBlocks` | Blocks that make this version need approval (risky block types, and App actions registered as risky) |

The answer is always `200` when the document could be read; `valid` says whether it would be stored. When the client
app has no `AI` feature, each block that uses AI (AI Agent, `speech.match: "smart"`) is an error with code `FEATURE_OFF`.

---

# B. Accounts

An **account** is one business. Its id is used in most paths. `externalRef` is the client app's own id for it.

## B1. Create an account
**Purpose**: Add a business. It starts with sensible defaults (hours Mon–Fri 08–17, Sat 09–13; quiet hours 21:00–07:00;
recording of live parts with a Kiswahili notice; limits; 90 days retention).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{
  "externalRef": "pmk-chemicals",
  "defaultLanguage": "sw",
  "timezone": "Africa/Dar_es_Salaam"
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `externalRef` | string | Yes | The client app's id for the business | Not blank, max 120; unique within the client app |
| `defaultLanguage` | string | No | Language callers hear first | `sw` (default), `en` |
| `timezone` | string | No | For hours, quiet hours, "call me at 9" | IANA zone; default `Africa/Dar_es_Salaam` |

**Success Response**: `201 Created`, `data` = the account, same shape as [B3](#b3-get-an-account).

**Errors**:
```json
{ "success": false, "httpStatus": "409 CONFLICT", "message": "An account with this externalRef already exists",
  "code": "ACCOUNT_EXISTS", "action_time": "2026-11-13T19:57:58.985027739", "data": null }
```
```json
{ "success": false, "httpStatus": "422 UNPROCESSABLE_CONTENT", "message": "Validation failed",
  "code": "VALIDATION_FAILED", "action_time": "2026-11-13T19:57:59.051669172", "data": { "externalRef": "must not be blank" } }
```
```json
{ "success": false, "httpStatus": "400 BAD_REQUEST", "message": "Unknown timezone: Mars/Base",
  "code": "BAD_REQUEST", "action_time": "2026-11-13T19:57:59.009578709", "data": null }
```

---

## B2. List accounts
**Purpose**: All accounts of the client app, or the one with a given `externalRef`.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts`

**Access Level**: 🔒 Protected · Bearer API key

| Query | Type | Required | Description |
|-----------|------|----------|-------------|
| `externalRef` | string | No | Return only this account (a list of 0 or 1) |

**Success Response**: `data` = array of accounts ([B3](#b3-get-an-account) shape).

---

## B3. Get an account
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}`

**Access Level**: 🔒 Protected · Bearer API key

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Account",
  "action": null,
  "action_time": "2026-11-13T19:57:58.939219236",
  "data": {
    "id": "adb72fac-66a2-471b-a437-035fe008197d",
    "externalRef": "softphone",
    "status": "ACTIVE",
    "defaultLanguage": "sw",
    "timezone": "Africa/Dar_es_Salaam",
    "hours": {
      "days": {
        "MONDAY": [ { "from": "08:00", "to": "17:00" } ],
        "TUESDAY": [ { "from": "08:00", "to": "17:00" } ],
        "WEDNESDAY": [ { "from": "08:00", "to": "17:00" } ],
        "THURSDAY": [ { "from": "08:00", "to": "17:00" } ],
        "FRIDAY": [ { "from": "08:00", "to": "17:00" } ],
        "SATURDAY": [ { "from": "09:00", "to": "13:00" } ]
      }
    },
    "quietHours": { "from": "21:00", "to": "07:00" },
    "recording": {
      "mode": "LIVE_PARTS",
      "noticeText": "Mazungumzo haya yanarekodiwa kwa ajili ya ubora wa huduma.",
      "noticeMediaId": null,
      "transcribe": true
    },
    "limits": {
      "maxCallMinutes": 15,
      "maxWaitSeconds": 300,
      "callerCallsPer10Min": 100,
      "otpPerDestinationPer10Min": 3,
      "voicemailMaxSeconds": 120,
      "maxBlockVisits": 25
    },
    "hasRequestSigningSecret": false,
    "retentionDays": 90,
    "businessName": "Ringmaster Lab",
    "aiDisclosure": null,
    "createdAt": "2026-11-12T05:20:55.204082Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `ACTIVE` · `SUSPENDED` (every call to its numbers is refused) |
| `hours.days` | Opening hours per day (`MONDAY`…`SUNDAY`), several ranges allowed; a missing day = closed. Used by the Condition block (`hours` test) and Connect |
| `quietHours` | No call-outs between `from` and `to` (may cross midnight) |
| `recording.mode` | `LIVE_PARTS` (record talk with agents) · `OFF` (only voicemails) |
| `recording.noticeText` / `noticeMediaId` | What callers hear before a recorded part (text, or an uploaded audio file) |
| `recording.transcribe` | Make a transcript and summary of each recording |
| `limits.*` | Guards, see [Part 1 §1.4](1-how-it-works.md#14-an-incoming-call-step-by-step) |
| `hasRequestSigningSecret` | A secret is set for signing Send request blocks' calls to the business's own systems (the secret itself is never returned) |
| `retentionDays` | Recordings and transcripts are deleted after this many days |
| `businessName` | Said in the AI notice ("…huduma ya kidijitali ya *Ringmaster Lab*") |
| `aiDisclosure` | Own AI notice per language `{ "sw": "…", "en": "…" }`; `null` = the engine's default sentence |
| `recording.agentCallNotice` | Also say the notice on calls agents make (default `false`) |
| `agentPhone.showTeam` | Agents see who else in their departments is available, on a call or away (default `true`) |
| `agentPhone.showCallerNumbers` | `false` masks numbers for agents (`+2557•••••250`) in rings, queues, recent calls and search |
| `agentPhone.allowDecline` | Agents may decline rings (`false` = calls are assigned; decline answers `409 DECLINE_NOT_ALLOWED`) |
| `agentPhone.allowCallBack` | Agents may call earlier callers back from their app ([J8](2d-agents.md#j8-call-someone-back)) |
| `agentPhone.allowDialPad` | Agents may dial any number from Agent Phone's dial pad |

**Errors**: `404 NOT_FOUND` "Account not found" (unknown, or another client app's).

---

## B4. Update an account
**Purpose**: Change any setting. Send only what changes; missing fields keep their value.

**Endpoint**: <span style="background-color: #fd7e14; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PATCH</span> `{base_url}/accounts/{accountId}`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{
  "businessName": "PMK Chemicals",
  "hours": { "days": { "MONDAY": [ { "from": "08:00", "to": "12:30" }, { "from": "14:00", "to": "17:00" } ] } },
  "quietHours": { "from": "20:00", "to": "08:00" },
  "recording": { "mode": "LIVE_PARTS", "noticeText": "Simu hii inarekodiwa.", "transcribe": true },
  "limits": { "maxCallMinutes": 20, "maxWaitSeconds": 300, "callerCallsPer10Min": 5,
              "otpPerDestinationPer10Min": 3, "voicemailMaxSeconds": 120, "maxBlockVisits": 25 },
  "retentionDays": 180,
  "aiDisclosure": { "sw": "Unazungumza na msaidizi wa kidijitali wa PMK.", "en": "You are talking to PMK's digital assistant." }
}
```

| Parameter | Type | Description | Validation |
|-----------|------|-------------|------------|
| `status` | string | `ACTIVE` · `SUSPENDED` | Suspending refuses new calls to its numbers and **pauses** its running call-outs; going back to `ACTIVE` does not restart them (the messages may be stale): resume or stop each one |
| `defaultLanguage` | string | `sw` · `en` | |
| `timezone` | string | IANA zone | `400 Unknown timezone` |
| `hours` | object | **Replaces** all opening hours | `HH:mm` |
| `quietHours` | object | `{from, to}` | `HH:mm` |
| `recording` | object | **Replaces** the recording policy | `mode` = `OFF` / `LIVE_PARTS` |
| `limits` | object | **Replaces** all six limits (send all six) | whole numbers |
| `requestSigningSecret` | string | Secret for Send request signatures; `""` removes it | stored encrypted |
| `retentionDays` | integer | Days to keep recordings | 1–3650 |
| `businessName` | string | `""` removes it | |
| `aiDisclosure` | object | `{}` goes back to the default sentence | |
| `agentPhone` | object | **Replaces** the five switches (send all five); missing ones count as `true`. Agents' devices are told at once (`config` signal) | booleans |

**Success Response**: the updated account ([B3](#b3-get-an-account) shape).

---

# C. Numbers

A number moves through three states:

```
POOL ──(owner gives it to the client app)──► ALLOCATED ──assign──► ASSIGNED (to one account) ──release──► ALLOCATED
                                                  ▲  remove (NUMBERS feature) ◄── POOL      transfer: account → account
```
Only an **assigned** number with a pinned, runnable **Incoming** flow answers calls.

## C1. List numbers
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/numbers`

**Access Level**: 🔒 Protected · Bearer API key

| Query | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | `ALLOCATED` (free, not on an account) · `ASSIGNED`. Anything else → `400 Invalid value for parameter: status` |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Numbers",
  "action": null,
  "action_time": "2026-11-13T19:58:07.321779977",
  "data": [
    {
      "id": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66",
      "e164": "+255677000001",
      "status": "ASSIGNED",
      "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
      "label": null,
      "incomingScriptVersionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a",
      "verified": true,
      "verifiedAt": "2026-11-12T05:33:27.481181Z",
      "ringMode": "BROWSER"
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `status` | `ALLOCATED` · `ASSIGNED` |
| `accountId` | The account it is on (`null` when free) |
| `label` | The client app's name for it ("Mauzo", "Line 7") |
| `incomingScriptVersionId` | The flow version callers hear; `null` = calls are refused |
| `verified` / `verifiedAt` | A real call has reached the engine through the trunk on this number |
| `ringMode` | Where agents are rung: `BROWSER` · `MOBILE` · `BROWSER_AND_MOBILE` |

---

## C2. Add a number
**Purpose**: Bring a Tanzanian number the client app owns at the carrier into Ringmaster (it must be routed to the
engine's trunk). Needs the `NUMBERS` feature.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/numbers`

**Access Level**: 🔒 Protected · Bearer API key · feature `NUMBERS`

**Request JSON Sample**:
```json
{ "e164": "+255677000099", "type": "REGULAR", "label": "Mauzo", "by": "admin@business.co.tz" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `e164` | string | Yes | The number | `+255` and 9 digits |
| `type` | string | No | `REGULAR` (default) · `TOLL_FREE` | |
| `label` | string | No | Name for it | max 80 |
| `by` | string | No | Who did it in the client app (kept in the audit log) | max 120 |

**Success Response**: `201 Created`, `data` = the number (status `ALLOCATED`).

**Errors**:
```json
{ "success": false, "httpStatus": "403 FORBIDDEN", "message": "Adding and removing numbers is not enabled for this client app",
  "code": "FEATURE_OFF", "action_time": "2026-11-13T19:58:07.349143236", "data": null }
```
`400` "Only Tanzanian numbers (+255 and 9 digits) can be added" · `409 NUMBER_ALREADY_YOURS` · `409 NUMBER_TAKEN` (another client app has it).

---

## C3. Remove a number
**Purpose**: Give a free number back to the pool. Needs the `NUMBERS` feature.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/numbers/{numberId}`

**Access Level**: 🔒 Protected · Bearer API key · feature `NUMBERS`

| Query | Type | Required | Description |
|-----------|------|----------|-------------|
| `by` | string | No | Who did it (audit log) |

**Success Response**: `200`, `message` "Number removed", `data` null.

**Errors**: `409 NUMBER_ASSIGNED` (release it first) · `409 NUMBER_BUSY` (a call is in progress) · `409 NUMBER_IN_CALLOUT` (a scheduled, running or paused call-out calls from it).

---

## C4. Assign a number to an account
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/numbers/{numberId}/assign`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{ "accountId": "adb72fac-66a2-471b-a437-035fe008197d", "label": "Mapokezi" }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `accountId` | uuid | Yes | The account |
| `label` | string | No | Name for the number |

**Success Response**: the number with `status: "ASSIGNED"`. Assigning again to the same account only changes the label.

**Errors**: `409 NUMBER_ASSIGNED` "Number is assigned to another account; release it first" (use [C6](#c6-transfer-a-number-to-another-account) instead).

---

## C5. Release a number from its account
**Purpose**: Take the number off the account; it becomes free (`ALLOCATED`) and its flow is cleared.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/numbers/{numberId}/release`

**Access Level**: 🔒 Protected · Bearer API key

**Success Response**: the number with `status: "ALLOCATED"`, `accountId: null`, `incomingScriptVersionId: null`.

**Errors**: `409 NUMBER_BUSY` · `409 NUMBER_IN_CALLOUT`.

---

## C6. Transfer a number to another account
**Purpose**: Move an assigned number straight to another account of the same client app. The old account's flow is
cleared, so pin the new account's flow next ([C7](#c7-put-a-flow-on-a-number)).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/numbers/{numberId}/transfer`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{ "accountId": "51f815ba-e9eb-4e29-9297-3c56033d32cd", "label": "Oda", "by": "admin@business.co.tz" }
```

**Errors**:
```json
{ "success": false, "httpStatus": "409 CONFLICT", "message": "The number is already on this account",
  "code": "SAME_ACCOUNT", "action_time": "2026-11-13T19:58:07.424064672", "data": null }
```
`409 NUMBER_NOT_ASSIGNED` (assign it instead) · `409 ACCOUNT_NOT_ACTIVE` · `409 NUMBER_BUSY` · `409 NUMBER_IN_CALLOUT`.

---

## C7. Put a flow on a number
**Purpose**: Choose the flow version callers hear. Takes effect for the next call; calls in progress keep their version.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/numbers/{numberId}/script`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{ "scriptVersionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a" }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `scriptVersionId` | uuid / null | Yes | A runnable version of an **Incoming** flow of the number's account (`versionId` from [Part 2B](2b-flows.md)); `null` takes the flow off (calls are refused) |

**Success Response**: the number, `message` "Incoming script set".

**Errors**:
- `400` "Assign the number to an account first"
- `400` "Script version belongs to another account" · `400` "Numbers run Incoming scripts only"
- `404 NOT_FOUND` "Script version not found"
- `409 VERSION_NOT_RUNNABLE` "Only runnable versions can be put on a number" (approve it, [Part 2B D7](2b-flows.md#d7-approve-a-version-make-it-runnable))

---

## C8. Set where agents are rung
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/numbers/{numberId}/ring-mode`

**Access Level**: 🔒 Protected · Bearer API key

**Request JSON Sample**:
```json
{ "ringMode": "BROWSER_AND_MOBILE" }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `ringMode` | string | No | `BROWSER` (default; also when missing) · `MOBILE` (agents' `mobile` numbers) · `BROWSER_AND_MOBILE` |

**Success Response**: the number, `message` "Ring mode set".

**Errors**: an unknown value → `400` "Request body is not valid JSON".

# Part 2B — Call flows, media, knowledge base

[← Contents](README.md) · Previous: [Part 2A — Setup](2a-setup.md) · Next: [Part 2C — Calls](2c-calls.md)

A **call flow** (API name: *script*) is identified by its `ref` inside an account (`orders`, `account-balance`). Its
content lives in numbered **versions** that never change once stored. How to write the document itself (blocks,
settings, exits, messages, variables): [Part 5](5-flow-reference.md). Life of a flow: [Part 1 §1.6](1-how-it-works.md#16-a-call-flows-life).

All endpoints: 🔒 Protected · Bearer API key. `{accountId}` must be one of the client app's accounts (else `404`).

---

# D. Call flows (scripts)

## D1. List flows
**Purpose**: Every flow of an account with all its versions (deleted flows are not listed).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts`

**Success Response**: `data` = array of flows, sorted by `ref`, each as in [D3](#d3-get-a-flow).

---

## D2. Create or rename a flow
**Purpose**: Make the flow (before its first version), or change its display name. Calling it again with the same
`ref` only renames.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Request JSON Sample**:
```json
{ "name": "Account balance", "direction": "INCOMING" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `{ref}` (path) | string | Yes | The client app's id for the flow | max 120; letters, digits, `-`, `_` |
| `name` | string | Yes | Display name | not blank |
| `direction` | string | Yes | `INCOMING` (plays on a number) · `OUTGOING` (plays when a called person answers) | fixed once created |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script saved",
  "action": null,
  "action_time": "2026-11-13T20:00:15.876328395",
  "data": {
    "id": "d3c063a6-1d76-415e-b623-c5769c1f6e69",
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "versions": []
  }
}
```

**Errors**: `409 DIRECTION_FIXED` "A script keeps its direction; duplicate it to use the other direction".
A flow that was deleted after taking calls (archived) comes back with its old versions, which stay stopped.

---

## D3. Get a flow
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script",
  "action": null,
  "action_time": "2026-11-13T20:00:42.882898745",
  "data": {
    "id": "d3c063a6-1d76-415e-b623-c5769c1f6e69",
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "versions": [
      {
        "versionId": "65a926f5-9fdc-49ed-95a7-93a70e562460",
        "version": 1,
        "runnable": true,
        "testOnly": false,
        "revoked": false,
        "vars": [],
        "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
        "riskyBlocks": ["acct"],
        "warnings": [],
        "createdAt": "2026-11-13T17:00:15.991897Z"
      },
      {
        "versionId": "15baa310-3997-4d14-a66e-a9c024f909ec",
        "version": 2,
        "runnable": false,
        "testOnly": false,
        "revoked": false,
        "vars": [],
        "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
        "riskyBlocks": ["acct"],
        "warnings": [],
        "createdAt": "2026-11-13T17:00:42.855216Z"
      }
    ]
  }
}
```

| Field | Description |
|-------|-------------|
| `versions[].versionId` | What numbers (`PUT /v1/numbers/{id}/script`) and call-outs (`scriptVersionId`) point at |
| `versions[].version` | The number the client app gave it |
| `runnable` | May run: be put on a number or used for calls out |
| `testOnly` | For test calls only; never runnable |
| `revoked` | Stopped for good |
| `vars` | Variables a call-out must give each person (`name`, `amount`); system variables are not listed |
| `texts` | Fixed sentences, already turned into audio. Sentences with variables (`Akaunti {input.account}…`) are spoken during the call |
| `riskyBlocks` | Blocks that make the version need approval ([D7](#d7-approve-a-version-make-it-runnable)) |
| `warnings` | Only in the answer to a push ([D5](#d5-push-a-version)); empty when read later |

**Errors**: `404 NOT_FOUND` "Script not found: docs-sample".

---

## D4. Delete a flow
**Purpose**: Remove a flow. Refused while anything uses it. A flow that never ran is erased; a flow that ran calls is
**archived**: hidden from lists, all its versions stopped, its call history kept. Creating the same `ref` again
([D2](#d2-create-or-rename-a-flow)) brings it back.

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/scripts/{ref}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script deleted",
  "action": null,
  "action_time": "2026-11-13T20:00:43.15694553",
  "data": { "ref": "docs-sample", "outcome": "DELETED" }
}
```

| Field | Description |
|-------|-------------|
| `outcome` | `DELETED` (never ran: gone) · `ARCHIVED` (ran calls: hidden, history kept) |

**Error Response JSON Sample** (still in use):
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "The script is still in use: move these to another script first",
  "code": "SCRIPT_IN_USE",
  "action_time": "2026-11-13T18:09:38.582694597",
  "data": {
    "numbers": ["+255677000003"],
    "callouts": ["Callback +255745051250"],
    "scripts": ["account v4"]
  }
}
```

| `data` key | Means |
|---|---|
| `numbers` | Numbers playing one of its versions → put another flow on them ([2A C7](2a-setup.md#c7-put-a-flow-on-a-number)) |
| `callouts` | Call-outs not finished (draft, scheduled, running, paused) using it → cancel or let them finish |
| `scripts` | Live versions of other flows that transfer to it or schedule callbacks with it → push new versions without that |

---

## D5. Push a version
**Purpose**: Store a new version. The engine checks it first ([Part 5 §5.9](5-flow-reference.md#59-check-codes)): errors → nothing stored.
Fixed sentences are turned into audio straight away, and an AI Agent's knowledge is prepared in the background.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions`

**Request JSON Sample**:
```json
{
  "version": 2,
  "runnable": false,
  "definition": {
    "direction": "INCOMING",
    "start": { "call": "acct" },
    "blocks": {
      "acct": { "type": "collect", "msg": { "src": "text", "text": "Bonyeza namba yako ya akaunti." },
                "saveAs": "account", "minDigits": 4, "maxDigits": 4,
                "x": { "ok": "read", "failed": "bye", "later": "bye" } },
      "read": { "type": "say", "msg": { "src": "text", "text": "Akaunti {input.account} ina salio la shilingi elfu kumi." },
                "x": { "next": "bye" } },
      "bye":  { "type": "goodbye", "msg": { "src": "text", "text": "Asante, kwaheri." } }
    }
  }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `version` | integer | Yes | The version number; the client app counts | ≥ 1, unique per flow |
| `definition` | object | Yes | The flow document | must pass the check; `direction` must match the flow; a `layout` key is dropped |
| `runnable` | boolean | No | `true` = may run at once. Push `false` when the version has `riskyBlocks` and waits for approval | default `false` |
| `testOnly` | boolean | No | For test calls only (never runnable) | default `false` |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Version stored",
  "action": null,
  "action_time": "2026-11-13T20:00:16.018139374",
  "data": {
    "versionId": "65a926f5-9fdc-49ed-95a7-93a70e562460",
    "version": 1,
    "runnable": true,
    "testOnly": false,
    "revoked": false,
    "vars": [],
    "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
    "riskyBlocks": ["acct"],
    "warnings": [
      { "level": "WARNING", "block": "acct", "code": "EXIT_UNCONNECTED", "message": "Exit 'later' is not connected: the call ends there" }
    ],
    "createdAt": "2026-11-13T17:00:15.991897467Z"
  }
}
```

**Sending the same version again** with the same document (e.g. a retry after a timeout) is safe: `200 OK`,
`message` "Version already stored", same `data`. Secrets in Send request blocks are encrypted before storing and do not
change the comparison.

**Error Response JSON Samples**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Script version is not valid",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:00:16.178937467",
  "data": [
    { "level": "ERROR", "block": "a", "code": "EXIT_TARGET_MISSING", "message": "Exit 'next' points to a missing block: zz" }
  ]
}
```
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "Version 1 already exists with a different definition; versions are immutable",
  "code": "VERSION_EXISTS",
  "action_time": "2026-11-13T20:00:16.123223371",
  "data": null
}
```
Also `400` "definition must be a JSON object" · `404` "Script not found" (create it with [D2](#d2-create-or-rename-a-flow) first) ·
`403 FEATURE_OFF` "AI is not enabled for this client app; these blocks use it: ai" (an AI Agent block or smart speech
matching without the `AI` feature). Check codes: [Part 4](4-reference.md#script-check-codes).

---

## D6. Get a version (with its blocks)
**Purpose**: Read a version back: to show it, edit it into the next version, or see what a call ran.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Script version",
  "action": null,
  "action_time": "2026-11-13T20:00:42.918636988",
  "data": {
    "ref": "docs-sample",
    "name": "Account balance",
    "direction": "INCOMING",
    "version": {
      "versionId": "15baa310-3997-4d14-a66e-a9c024f909ec",
      "version": 2,
      "runnable": false,
      "testOnly": false,
      "revoked": false,
      "vars": [],
      "texts": ["Bonyeza namba yako ya akaunti.", "Asante, kwaheri."],
      "riskyBlocks": ["acct"],
      "warnings": [],
      "createdAt": "2026-11-13T17:00:42.855216Z"
    },
    "definition": {
      "direction": "INCOMING",
      "start": { "call": "acct" },
      "blocks": {
        "acct": { "type": "collect", "msg": { "src": "text", "text": "Bonyeza namba yako ya akaunti." },
                  "saveAs": "account", "minDigits": 4, "maxDigits": 4,
                  "x": { "ok": "read", "later": "bye", "failed": "bye" } },
        "read": { "type": "say", "msg": { "src": "text", "text": "Akaunti {input.account} ina salio la shilingi elfu kumi." },
                  "x": { "next": "bye" } },
        "bye":  { "type": "goodbye", "msg": { "src": "text", "text": "Asante, kwaheri." } }
      }
    }
  }
}
```

| Field | Description |
|-------|-------------|
| `version` | Same as in [D3](#d3-get-a-flow) |
| `definition` | The stored document. Keys come back sorted. Secrets of Send request blocks (`auth.token`, `auth.password`, `auth.value`) come back as `"(hidden)"`. Pushed back unchanged in a new version, `"(hidden)"` keeps the stored secret of the same block |

**Errors**: `404` "Version not found: 9".

---

## D7. Approve a version (make it runnable)
**Purpose**: After the client app's reviewer approves a version pushed with `runnable: false`. Call-outs that were
waiting for it start by themselves.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/runnable`

**Success Response**: `200`, `message` "Version is runnable", `data` = the version (`runnable: true`).

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "A revoked version cannot become runnable",
  "code": "VERSION_REVOKED",
  "action_time": "2026-11-13T20:00:43.054366231",
  "data": null
}
```

---

## D8. Stop a version (revoke)
**Purpose**: Stop a version for good (it was wrong, or the business rejected it). It can never run again.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/revoke`

**Success Response**: `200`, `message` "Version revoked", `data` = the version (`revoked: true`, `runnable: false`).
Revoking twice is harmless.

> **Move things off it first.** A number still playing a revoked version refuses calls (callers hear that the
> service is unavailable), and running call-outs using it are paused with `pauseReason: "VERSION_REVOKED"`.

---

## D9. Block stats of a version
**Purpose**: Where callers go and where they get stuck, per block — for the flow editor's heat map.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` | date | No | First day, `YYYY-MM-DD` (account's time zone) | 6 days before `to` |
| `to` | date | No | Last day | today |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Block stats",
  "action": null,
  "action_time": "2026-11-13T20:01:13.597312525",
  "data": [
    { "key": "acct", "values": { "visits": 5, "hangups": 0, "noInput": 0, "retries": 2,
                                  "reqOk": 0, "reqFail": 0, "reqTimeout": 0, "reqMsP50": null, "gaveUp": 0 } },
    { "key": "when", "values": { "visits": 4, "hangups": 0, "noInput": 0, "retries": 1,
                                  "reqOk": 0, "reqFail": 0, "reqTimeout": 0, "reqMsP50": null, "gaveUp": 0 } }
  ]
}
```

| Field | Description |
|-------|-------------|
| `key` | Block id |
| `visits` | Times a call entered the block |
| `hangups` | Callers who hung up in it |
| `noInput` | Times nobody answered its question |
| `retries` | Questions asked again (wrong or no answer) |
| `reqOk` / `reqFail` / `reqTimeout` / `reqMsP50` | Send request blocks: results and median time in ms |
| `gaveUp` | Waiting rooms the caller left after the give-up time |

Stats are rolled up a few minutes after calls end.

---

# E. Media (audio files)

Recorded greetings, menu prompts and hold music. Upload once, then use the id in a flow message:
`{"src": "upload", "media": "<mediaId>"}` (a waiting room's `music` takes the id directly).

## E1. Upload audio
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/media` · `multipart/form-data`

| Part / query | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `file` (part) | file | Yes | MP3, WAV, OGG, M4A… | an audio file; ≤ 10 MB; ≤ 5 minutes |
| `name` (query) | string | No | Display name | default: the file name |
| `lang` (query) | string | No | `sw` · `en` (for the library filter) | |

```bash
curl -X POST "$BASE/accounts/$ACCOUNT/media?name=Salamu%20ya%20duka&lang=sw" \
  -H "Authorization: Bearer $RINGMASTER_KEY" -F "file=@salamu.mp3"
```

**Success Response**: `202 Accepted`, `message` "Audio received; processing", `data` = the media item with `status: "PENDING"`. The engine converts it for the
phone line in the background, then sends **`media.ready`** (`{mediaId, durationMs}`) or **`media.failed`**
(`{mediaId, error}`) — [Part 3](3-webhooks.md). Use the id in a flow only once it is `READY`.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Upload rejected",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:01:13.708314472",
  "data": [ { "field": "file", "message": "Not an audio file" } ]
}
```
Other messages: "File is larger than 10 MB" · "Only audio files are accepted" · "Audio is longer than 5 minutes".
`409 STORAGE_QUOTA` — the account has used its 500 MB of audio.

---

## E2. List audio
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/media`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Audio library",
  "action": null,
  "action_time": "2026-11-13T20:01:13.551808448",
  "data": [
    {
      "id": "eebb855e-2a03-4af7-a8c8-301f85877308",
      "name": "Muziki wa kusubiri",
      "kind": "UPLOAD",
      "status": "READY",
      "durationMs": 24192,
      "lang": "sw",
      "error": null,
      "previewUrl": "https://<ringmaster-host>/media/eebb855e-…/eda4fdcb….mp3?t=f702ec7bb40e96291e689462aa8319c9",
      "createdAt": "2026-11-13T09:46:03.541625Z"
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `kind` | `UPLOAD` (uploaded) · `RECORDING` (recorded in the editor) · `SYSTEM` (engine's own: beeps, default hold music) |
| `status` | `PENDING` → `PROCESSING` → `READY` · `FAILED` (see `error`) |
| `durationMs` | Length once ready |
| `previewUrl` | Signed link to play it in a browser (`<audio src>`), no API key needed; it expires — read it again rather than storing it |

---

## E3. Get one audio item
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/media/{mediaId}`

**Success Response**: one item as in [E2](#e2-list-audio). **Errors**: `404` "Audio not found".

---

## E4. Delete audio
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/media/{mediaId}`

**Success Response**: `200`, `message` "Audio deleted", `data` null.

**Errors**: `404` "Audio not found" · `409 MEDIA_IN_USE` "Audio is used by live script versions", `data: {"usedBy": ["orders v3", "reception v2"]}`
— push versions without it (or revoke those) first.

---

# F. Knowledge base (for AI Agents)

Documents an AI Agent may answer from. Upload them, then list their ids in the AI Agent block's `kb.docs`
([Part 5 §5.7](5-flow-reference.md#57-the-ai-agent)). Each flow version builds its own searchable copy when it is stored, so a version keeps
answering the same way even if documents change later; push a new version to use new documents.

## F1. Upload a document
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/kb-docs` · `multipart/form-data`

| Part | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `file` | file | Yes | `.txt`, `.md` or `.pdf` with text | ≤ 10 MB; text is cut at 200,000 characters; scanned PDFs (images) have no text |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Document stored",
  "action": null,
  "action_time": "2026-11-13T20:05:00.000000000",
  "data": { "id": "7c2e…", "name": "bei_2026.pdf", "contentType": "application/pdf", "chars": 18420,
            "status": "READY", "createdAt": "2026-11-13T17:05:00Z" }
}
```

**Errors** (`400 BAD_REQUEST`): "A file is required" · "Documents are limited to 10 MB" · "Upload a .txt, .md or .pdf document" ·
"No text found in this document (scanned PDFs are not read)".

---

## F2. List documents
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/kb-docs`

**Success Response**: `data` = array of documents as in [F1](#f1-upload-a-document).

## F3. Delete a document
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/kb-docs/{documentId}`

**Success Response**: `200`, `message` "Document removed", `data` null. Versions already stored keep their copy of its
text. **Errors**: `404` "Document not found".

# Part 2C — Calls, call-outs, OTP, reports

[← Contents](README.md) · Previous: [Part 2B — Call flows](2b-flows.md) · Next: [Part 2D — Agents](2d-agents.md)

All endpoints: 🔒 Protected · Bearer API key. A call or call-out of another client app gives `404`.

---

# G. Calls and their reports

Every call (in or out) is one record with a summary, a timeline of what happened, and — when something was recorded —
audio and a transcript. The same summary arrives in the `call.ended` webhook ([Part 3](3-webhooks.md)).

## G1. List calls
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `accountId` | uuid | No | One account (without it: all the client app's calls) | |
| `from` / `to` | ISO instant | No | Started between (`2026-11-13T00:00:00Z`) | |
| `type` | string | No | `INCOMING` · `BROADCAST` · `CAMPAIGN` · `OTP` · `AGENT_CALL` · `TEST` | |
| `result` | string | No | e.g. `MISSED`, `VOICEMAIL` ([Part 4](4-reference.md#calls)) | |
| `status` | string | No | e.g. `LIVE` for calls in progress | |
| `number` | string | No | Calls from or to this E.164 number | |
| `calloutId` | uuid | No | Calls placed by one call-out | |
| `page` / `size` | integer | No | Page (from 0) and page size | 0 / 50 |

**Success Response JSON Sample** (one call shown; each item is a [G2](#g2-get-a-call) summary):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Calls",
  "action": null,
  "action_time": "2026-11-13T20:05:32.490000000",
  "data": {
    "calls": [ { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "type": "INCOMING", "result": "VOICEMAIL", "…": "…" } ],
    "page": 0,
    "size": 50,
    "total": 65
  }
}
```
Newest first. A wrong value for a filter → `400 Invalid value for parameter: <name>`.

---

## G2. Get a call
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}`

**Success Response JSON Sample** (a caller left a voicemail while waiting for an agent):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call",
  "action": null,
  "action_time": "2026-11-13T20:05:32.497509178",
  "data": {
    "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96",
    "accountRef": "softphone",
    "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
    "type": "INCOMING",
    "direction": "INBOUND",
    "from": "+255745051250",
    "to": "+255677000001",
    "status": "ENDED",
    "result": "VOICEMAIL",
    "endReason": "SCRIPT_END",
    "endedBy": "SYSTEM",
    "handledBy": "VOICEMAIL",
    "startedAt": "2026-11-13T16:45:27.909648Z",
    "answeredAt": "2026-11-13T16:45:27.909648Z",
    "endedAt": "2026-11-13T16:46:20.533012Z",
    "durations": { "ringSeconds": null, "totalSeconds": 53, "scriptSeconds": 16, "waitSeconds": 35,
                   "talkSeconds": 0, "holdSeconds": 0, "aiSeconds": 0 },
    "keys": null,
    "lastBlock": "vm",
    "outcomes": [],
    "usage": { "pstnSeconds": 53, "trunkSeconds": 53, "waitSeconds": 35, "aiSeconds": 0, "ttsChars": 0, "sttSeconds": 0 },
    "providerCost": {
      "tzs": 25.61,
      "usd": 0.000893,
      "items": [
        { "provider": "AT", "tzs": 25.61, "usd": 0.0, "lines": [
          { "item": "AT_INBOUND", "qty": 1, "unit": "MINUTE", "rate": 17, "currency": "TZS", "amount": 17, "source": "RATE", "model": null },
          { "item": "AT_SIP_TRUNK", "qty": 1, "unit": "MINUTE", "rate": 8.61, "currency": "TZS", "amount": 8.61, "source": "RATE", "model": null } ] },
        { "provider": "OPENAI", "tzs": 0.0, "usd": 0.000893, "lines": [
          { "item": "OPENAI_CHAT_IN", "qty": 474, "unit": "TOKEN", "rate": 1e-07, "currency": "USD", "amount": 4.74e-05, "source": "OPENAI", "model": "gpt-6-luna" },
          { "item": "OPENAI_CHAT_OUT", "qty": 42, "unit": "TOKEN", "rate": 5e-07, "currency": "USD", "amount": 2.1e-05, "source": "OPENAI", "model": "gpt-6-luna" },
          { "item": "OPENAI_TRANSCRIBE", "qty": 0.1833, "unit": "MINUTE", "rate": 0.0045, "currency": "USD", "amount": 0.00082485, "source": "MEASURED", "model": "gpt-transcribe" } ] }
      ]
    },
    "recorded": true,
    "script": { "versionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a", "version": 1, "ref": "desk-test" },
    "calloutId": null,
    "hangupCause": null,
    "sipStatus": null,
    "meta": null
  }
}
```

| Field | Description |
|-------|-------------|
| `type` / `direction` | What kind of call ([Part 4](4-reference.md#calls)); `INBOUND` = someone called us |
| `from` / `to` | E.164 numbers. Incoming: caller → our number. Outgoing: our number → person |
| `status` | Live state; `ENDED` once over |
| `result` | How it went, set when it ends: `HANDLED_BY_SCRIPT` · `HANDLED_BY_AGENT` · `HANDLED_BY_AI` · `VOICEMAIL` · `MISSED` · `ABANDONED` · `HUNG_UP_EARLY` · `REJECTED`; outgoing: `ANSWERED` · `NO_ANSWER` · `BUSY` · `DECLINED` · `UNREACHABLE` · `INVALID_NUMBER` · `FAILED` · `NOT_DIALED` |
| `endReason` / `endedBy` | Why and by whom it ended (`CALLER_HUNG_UP` / `CALLER`, `SCRIPT_END` / `SYSTEM`…) |
| `handledBy` | Who dealt with the caller in the end: `SCRIPT` · `AGENT` · `AI` · `VOICEMAIL` |
| `answeredAt` | Incoming: when the engine picked up (= start). Outgoing: when the person answered; `null` if never |
| `durations` (s) | `ringSeconds` (outgoing, before answer) · `totalSeconds` · `scriptSeconds` (in blocks) · `waitSeconds` (waiting room) · `talkSeconds` (with an agent) · `holdSeconds` · `aiSeconds` |
| `keys` | Every key the caller pressed, in order (`"1234"`) |
| `lastBlock` | The block the call ended in |
| `outcomes` | Tags the flow set on the way (`callback_scheduled`, `opted_out`, an App action's outcome…) |
| `usage` | Measured units: phone seconds (`pstnSeconds`, `trunkSeconds`), `ttsChars` (new text made into audio for this call), `sttSeconds` (caller speech transcribed) |
| `providerCost` | Cost to run it, per provider and line ([Part 1 §1.11](1-how-it-works.md#111-costs)) |
| `recorded` | Something was recorded: `GET …/recording` works |
| `script` | The flow version it ran |
| `calloutId` | Set when a call-out placed it |
| `hangupCause` / `sipStatus` | Outgoing calls that failed: short network reason (`603 Decline`, `486 Busy here`) and SIP code |
| `meta` | What the client app attached (call-out `meta`, OTP `meta`, scheduled callbacks' `{said, scheduledBy}`) |

---

## G3. Timeline of a call
**Purpose**: Everything that happened, step by step: blocks entered, sentences said, keys, speech heard, rings,
transfers, recordings. For a call detail screen and for support.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/timeline`

**Success Response JSON Sample** (first events):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Timeline",
  "action": null,
  "action_time": "2026-11-13T20:05:32.529813711",
  "data": [
    { "seq": 1, "type": "CALL_RECEIVED", "block": null, "at": "2026-11-13T16:45:27.912895Z", "offsetMs": 3,
      "data": { "to": "+255677000001", "from": "+255745051250" } },
    { "seq": 2, "type": "SAY", "block": null, "at": "2026-11-13T16:45:27.915036Z", "offsetMs": 5,
      "data": { "text": "Habari, unazungumza na huduma ya kidijitali ya Ringmaster Lab.", "voice": "marin" } },
    { "seq": 4, "type": "BLOCK_ENTER", "block": "hi", "at": "2026-11-13T16:45:27.915108Z", "offsetMs": 5,
      "data": { "type": "say" } },
    { "seq": 9, "type": "QUEUED", "block": "desk", "at": "2026-11-13T16:45:40.577Z", "offsetMs": 12668,
      "data": { "department": "Mapokezi" } },
    { "seq": 12, "type": "WAIT_OPTION", "block": "desk", "at": "2026-11-13T16:46:03.869Z", "offsetMs": 35960,
      "data": { "key": "9", "exit": "vm" } },
    { "seq": 16, "type": "VOICEMAIL", "block": "vm", "at": "2026-11-13T16:46:17.441Z", "offsetMs": 49532,
      "data": { "recorded": true } }
  ]
}
```

| Field | Description |
|-------|-------------|
| `seq` | Order (gaps are possible) |
| `type` | What happened ([Part 4 — timeline events](4-reference.md#timeline-events)) |
| `block` | The block it happened in, if any |
| `offsetMs` | Milliseconds since the call started |
| `data` | Details for that type (texts said, keys, speech, department, exit taken…) |

A `SAY` event is written when the sentence is queued to play; the audio itself follows in order.

---

## G4. Recording of a call
**Purpose**: The MP3 of the call's latest recording (a voicemail, or the recorded part with an agent).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/recording`

**Success Response**: `200`, `Content-Type: audio/mpeg`, the audio bytes (not JSON). Stream it to the client app's
users through its own backend (the API key must not reach a browser).

**Errors** (JSON): `404` "No recording for this call" — nothing recorded, or not ready yet (wait for
`recording.ready`); `404` "Recording file missing" — deleted after `retentionDays`.

---

## G5. Transcript and summary
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/{callId}/transcript`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Transcript",
  "action": null,
  "action_time": "2026-11-13T20:05:32.551771069",
  "data": {
    "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96",
    "status": "READY",
    "language": "sw",
    "text": "Huu ndio ujumbe wangu, na shukrani sana.\n",
    "summary": "Mpigaji amesema huo ndio ujumbe wake na ametoa shukrani nyingi.",
    "sentiment": "positive",
    "unanswered": [],
    "fields": {},
    "model": "gpt-6-luna",
    "updatedAt": "2026-11-13T16:46:27.176884Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `READY` · `FAILED` (transcription failed; `text` is empty) |
| `language` | Detected language (`sw`, `en`) |
| `text` | What was said |
| `summary` | One or two sentences, in the call's language |
| `sentiment` | `positive` · `neutral` · `negative` |
| `unanswered` | Questions the caller asked that the AI could not answer (for the knowledge base) |
| `fields` | Values the flow asked the summary to pick out (e.g. `{"amount": "45000"}`), with fixed English keys |

Ready when the `summary.ready` webhook arrives. Before that, and when nothing was recorded or `recording.transcribe`
is off: `404` "No transcript for this call".

---

## G6. Export calls (CSV)
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/calls/export.csv`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `accountId` | uuid | Yes | The account | |
| `from` / `to` | date | No | Days `YYYY-MM-DD`, account time; at most 400 days | the last 7 days |

**Success Response**: `200`, `text/csv` attachment, up to 100,000 rows, oldest first. Header row:
```
callId,type,direction,fromE164,toE164,status,result,endReason,endedBy,handledBy,startedAt,answeredAt,endedAt,totalSeconds,waitSeconds,talkSeconds,aiSeconds,pstnSeconds,trunkSeconds,ttsChars,sttSeconds,hangupCause,sipStatus,keys,lastBlock,calloutId,agentRef,outcomes,meta
```

---

## G7. Agent calls a number (click to call)
**Purpose**: An agent calls a customer from the browser, from one of the account's numbers. The engine dials; the
agent joins the call room with the token returned and hears it ring.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/agent`

> **With Agent Phone** the agent's devices get an `outgoing` signal (`callId`, `to`, and the contact's `name`, `label`,
> `note`; or your `meta.name`) and join by themselves ([J7](2d-agents.md#j7-join-an-outgoing-call)); you don't need to
> hand them the room token. No recording notice is said on these calls unless `recording.agentCallNotice` is on.
> `meta.via: "dial-pad"` marks calls typed on Agent Phone's dial pad: refused with `409 DIAL_PAD_OFF` when the account
> turned `agentPhone.allowDialPad` off.

**Request JSON Sample**:
```json
{
  "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
  "numberId": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66",
  "to": "+255745051250",
  "agentRef": "asha",
  "meta": { "ticket": "T-1042" }
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `accountId` | uuid | Yes | The account |
| `numberId` | uuid | Yes | Calling from (one of the account's numbers) |
| `to` | string | Yes | Tanzanian number |
| `agentRef` | string | Yes | The agent who calls |
| `meta` | object | No | Kept on the call, returned in its summary and webhooks |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Calling",
  "action": null,
  "action_time": "2026-11-13T20:10:00.000000000",
  "data": {
    "callId": "6a1f…",
    "room": { "url": "wss://<livekit-host>", "room": "call-6a1f…", "identity": "agent-0ea57aeb-…", "token": "eyJhbGciOi…" }
  }
}
```
The browser joins `room` with `token` (LiveKit JS SDK, microphone on). The agent is `BUSY` until the call ends.

**Errors**: `404` "Agent not found: asha" · `409 AGENT_BUSY` · `403 CAN_CALL_REFUSED` (the client app's can-call
check said no, [Part 3](3-webhooks.md#32-the-can-call-check)) · `400` "Only Tanzanian numbers can be called".

---

# H. Call-outs (calling people)

Rules applied to every call-out (quiet hours, opt-outs, retries, pace): [Part 1 §1.5](1-how-it-works.md#15-calling-people-call-outs).
Call-outs play an **Outgoing** flow; its `vars` must be given for every person.

```
Quick call:  POST /v1/calls ─────────────────────────────── (created + people added + started in one go)
Step by step: POST /v1/callouts → POST …/{id}/recipients (one or more times) → POST …/{id}/start
              DRAFT ─start─► SCHEDULED (startsAt later) ─► RUNNING ⇄ PAUSED ─► COMPLETED
                │                  │                          │
                └──cancel──► CANCELLED ◄──cancel──┘           └──stop──► STOPPED
```

## H1. Quick call
**Purpose**: Call 1–1000 people now with one request ("Habari Asha, oda yako iko tayari").

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls`

**Request JSON Sample**:
```json
{
  "accountId": "51f815ba-e9eb-4e29-9297-3c56033d32cd",
  "scriptVersionId": "9f0d2a77-…",
  "fromNumberId": "2c4e…",
  "name": "Order ready",
  "to": [
    { "e164": "+255745051250", "vars": { "product": "Acetic Acid", "qty": "20" }, "meta": { "orderId": "ORD-88" } },
    { "e164": "+255712345678", "vars": { "product": "Zinc Dust", "qty": "5" } }
  ],
  "maxTries": 3,
  "retryGapSec": 300,
  "meta": { "batch": "evening" }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | The account | |
| `scriptVersionId` | uuid | Yes | A version of an **Outgoing** flow of the account | not revoked |
| `fromNumberId` | uuid | Yes | Caller ID: one of the account's numbers | |
| `to` | array | Yes | People: `{e164, vars?, meta?}` | 1–1000 |
| `to[].vars` | object | When the flow has `vars` | Values the flow says (`{product}`) | every flow var, not blank |
| `to[].meta` | object | No | Kept on the person and their calls | |
| `name` | string | No | Shown in lists | default "API broadcast" |
| `maxTries` | integer | No | Tries per person (busy / no answer / unreachable / failed) | 1–3, default 1 |
| `retryGapSec` | integer | No | Wait between tries | 300–86400, default 300 |
| `meta` | object | No | Kept on the call-out | |

**Success Response**: `201 Created`, `message` "Calling", `data` = the call-out ([H7](#h7-get-a-call-out)), already
`RUNNING` (or `SCHEDULED` in quiet hours: it waits).

**Errors**:
- `403 FEATURE_OFF` "Call-outs are not enabled for this client app" (no `CALLOUTS` feature)
- `400` "Call-outs run Outgoing scripts only" · "Script version belongs to another account"
- `409 VERSION_REVOKED`
- `422 VALIDATION_FAILED` "Some people have no value for a script variable", `data: [{"e164": "+2557…", "missing": "product"}]`
- `422 VALIDATION_FAILED` "Nobody can be called", `data` = the skipped list ([H3](#h3-add-people))

---

## H2. Create a call-out
**Purpose**: A broadcast built in steps, or a **campaign** (a list called over days, in a window, at a pace).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts`

**Request JSON Sample** (campaign):
```json
{
  "accountId": "51f815ba-e9eb-4e29-9297-3c56033d32cd",
  "externalRef": "debt-reminder-2026-11",
  "name": "Debt reminder November",
  "kind": "CAMPAIGN",
  "scriptVersionId": "9f0d2a77-…",
  "fromNumberId": "2c4e…",
  "startsAt": "2026-11-14T06:00:00Z",
  "endsAt": "2026-11-21T15:00:00Z",
  "windowFrom": "09:00",
  "windowTo": "17:00",
  "days": [1, 2, 3, 4, 5],
  "maxTries": 3,
  "retryGapSec": 3600,
  "pacePerMin": 30,
  "fallback": { "action": "send_sms", "params": { "text": "Habari {name}, tafadhali lipa deni lako la {amount}." } },
  "meta": { "segment": "overdue-30" }
}
```

| Parameter | Type | Required | Description | Validation / default |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | | |
| `name` | string | Yes | | not blank |
| `kind` | string | Yes | `BROADCAST` · `CAMPAIGN` | |
| `scriptVersionId` | uuid | Yes | Outgoing flow version. May still wait for approval: the call-out shows `waitingApproval: true` and starts once approved | not revoked |
| `fromNumberId` | uuid | Yes | Caller ID | the account's number |
| `externalRef` | string | No | The client app's id | |
| `startsAt` | instant | No | Don't call before (`start` makes it `SCHEDULED`) | default: at start |
| `endsAt` | instant | No | Campaign: stop for good after this (`STOPPED`) | |
| `windowFrom` / `windowTo` | `HH:mm` | No | Campaign: only call between these each day (account time) | both or neither |
| `days` | int[] | No | Campaign: week days, 1 = Monday … 7 = Sunday | |
| `quietOverride` | boolean | No | Call even in quiet hours (urgent notices only) | default `false` |
| `maxTries` | integer | No | Tries per person | 1–5, default 1 |
| `retryGapSec` | integer | No | Wait between tries | ≥ 300, default 3600 |
| `pacePerMin` | integer | No | New calls per minute | 1–600, default 60 |
| `fallback` | object | No | Campaign: after a person's last failed try, ask the client app to run one of its registered actions (`{action, params}`); not one that waits | |
| `meta` | object | No | | |

**Success Response**: `201 Created`, `message` "Call-out created", `data` = the call-out, `status: "DRAFT"`.

**Errors**: `400` "Give both windowFrom and windowTo" · "Window times must be HH:mm" · "Days are 1 (Monday) to 7 (Sunday)" ·
"fallback.action must be one of the client app's registered actions" · "A fallback action can't be one that waits for an outcome" ·
`403 FEATURE_OFF` · the same flow errors as [H1](#h1-quick-call).

---

## H3. Add people
**Purpose**: Add people to a `DRAFT` call-out (call it several times for long lists).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts/{id}/recipients`

**Request JSON Sample**:
```json
{
  "recipients": [
    { "e164": "+255745051250", "vars": { "name": "Asha", "amount": "45000" }, "meta": { "customerId": "C-77" } },
    { "e164": "0712345678", "vars": { "name": "Juma", "amount": "12000" } },
    { "e164": "+14155550100", "vars": { "name": "Tom", "amount": "1" } }
  ]
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `recipients` | array | Yes | `{e164, vars?, meta?}` | ≤ 10,000 per request; every flow var given |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Recipients added",
  "action": null,
  "action_time": "2026-11-13T20:12:00.000000000",
  "data": {
    "added": 2,
    "skipped": [ { "e164": "+14155550100", "reason": "NOT_TANZANIAN" } ]
  }
}
```

| `skipped[].reason` | Means |
|---|---|
| `INVALID_NUMBER` | Not a phone number |
| `NOT_TANZANIAN` | Only `+255` numbers are called |
| `DUPLICATE` | Already on this call-out (or twice in the request) |
| `OPTED_OUT` | On the account's opt-out list: kept as `SKIPPED` with result `OPTED_OUT`, never called |

Local numbers (`0712…`) are turned into E.164.

**Errors**: `409 LIST_LOCKED` "The list is locked once a call-out starts; create a new call-out" ·
`422 VALIDATION_FAILED` "Some people have no value for a script variable" (nobody added).

---

## H4. Start, pause, resume, stop, cancel
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/callouts/{id}/{action}`

| `{action}` | From → to | Refused with |
|---|---|---|
| `start` | `DRAFT` → `RUNNING` (or `SCHEDULED` when `startsAt` is later). Locks the list | `409 ALREADY_STARTED` · `409 NO_RECIPIENTS` "Nobody to call" |
| `pause` | `RUNNING` / `SCHEDULED` → `PAUSED`; calls in progress finish. Optional body `{"reason": "…"}` (default `PAUSED_BY_CLIENT`) | `409 NOT_RUNNING` |
| `resume` | `PAUSED` → `RUNNING` | `409 NOT_PAUSED` |
| `stop` | any unfinished → `STOPPED`; people not yet called are not called. Stopping a finished call-out changes nothing | — |
| `cancel` | `DRAFT` / `SCHEDULED` → `CANCELLED` (nothing was dialled) | `409 ALREADY_RUNNING` "A running call-out is stopped, not cancelled" |

**Success Response**: `200`, `message` "Call-out start" (… "pause", "resume", "stop", "cancel"), `data` = the call-out.
Unknown action → `400` "Unknown action: explode".

The engine itself also pauses call-outs, with a `pauseReason`: `ACCOUNT_SUSPENDED`, `VERSION_REVOKED`, `FEATURE_OFF`
(the owner took `CALLOUTS` away), … and sends
`callout.paused` ([Part 3](3-webhooks.md)). It never resumes them by itself.

---

## H5. List call-outs
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts?accountId={accountId}`

| Query | Type | Required | Description |
|-----------|------|----------|-------------|
| `accountId` | uuid | **Yes** | Missing → `400 Missing parameter: accountId` |

**Success Response**: `data` = array of call-outs, newest first. Scheduled callbacks booked by callers are here too,
with `externalRef` `schedule:<flow ref>:<number>`.

---

## H6. Get a call-out's people
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/recipients`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `status` | string | No | `PENDING` · `DIALING` · `DONE` · `SKIPPED` | all |
| `page` / `size` | integer | No | Page from 0; size up to 500 | 0 / 100 |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Recipients",
  "action": null,
  "action_time": "2026-11-13T20:05:37.426862727",
  "data": [
    {
      "id": 11,
      "e164": "+255745051250",
      "status": "DONE",
      "result": "ANSWERED",
      "attempts": 1,
      "lastCallId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41",
      "answeredSeconds": 28,
      "keys": "1234",
      "outcomes": null,
      "meta": { "said": "After five minutes, baada ya dakika tano.", "scheduledBy": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf" },
      "nextAttemptAt": null
    }
  ]
}
```

| Field | Description |
|-------|-------------|
| `status` | `PENDING` (waiting or between tries) · `DIALING` · `DONE` · `SKIPPED` |
| `result` | The last try's result (`ANSWERED`, `NO_ANSWER`, `BUSY`, `DECLINED`, `OPTED_OUT`…) |
| `attempts` / `nextAttemptAt` | Tries so far; when the next try is due (while `PENDING`) |
| `lastCallId` | The last call to this person ([G2](#g2-get-a-call)) |
| `answeredSeconds` / `keys` / `outcomes` | From the answered call |

---

## H7. Get a call-out
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}`

**Success Response JSON Sample** (a callback a caller booked for "baada ya dakika tano"):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call-out",
  "action": null,
  "action_time": "2026-11-13T20:05:37.397238463",
  "data": {
    "id": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
    "externalRef": "schedule:account-callback:+255745051250",
    "name": "Callback +255745051250",
    "kind": "BROADCAST",
    "status": "COMPLETED",
    "pauseReason": null,
    "waitingApproval": false,
    "scriptVersionId": "3bcb496b-e302-4a98-9e8e-fd3e4c80b768",
    "startsAt": "2026-11-13T14:41:00Z",
    "endsAt": null,
    "windowFrom": null,
    "windowTo": null,
    "days": null,
    "maxTries": 2,
    "retryGapSec": 300,
    "pacePerMin": 60,
    "total": 1,
    "byStatus": { "DONE": 1 },
    "byResult": { "ANSWERED": 1 },
    "byOutcome": {},
    "createdAt": "2026-11-13T14:36:38.242481Z",
    "startedAt": "2026-11-13T14:36:38.282846Z",
    "completedAt": "2026-11-13T14:41:34.766757Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `status` / `pauseReason` | See the diagram above |
| `waitingApproval` | The flow version is not runnable yet; nobody is called until it is approved |
| `days` | Campaign days as text (`"1,2,3,4,5"`) |
| `total` / `byStatus` / `byResult` / `byOutcome` | Counts over its people |

---

## H8. Call-out stats
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/stats`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call-out stats",
  "action": null,
  "action_time": "2026-11-13T20:05:37.463714267",
  "data": {
    "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
    "total": 1,
    "byStatus": { "DONE": 1 },
    "byResult": { "ANSWERED": 1 },
    "byOutcome": {},
    "byAttempts": { "1": 1 },
    "byHour": [
      { "key": "2026-11-13T17:00", "values": { "dialed": 1, "answered": 1, "noAnswer": 0, "busy": 0, "failed": 0, "seconds": 28 } }
    ]
  }
}
```

| Field | Description |
|-------|-------------|
| `byAttempts` | People by the try that finished them (`{"1": 80, "2": 15, "3": 5}`) |
| `byHour[].key` | Hour in the account's time zone |
| `byHour[].values` | Calls dialled, answered, not answered, busy, failed; answered seconds |

## H9. Export a call-out (CSV)
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/callouts/{id}/export.csv`

**Success Response**: `text/csv` attachment, one row per person:
```
e164,status,result,attempts,answeredSeconds,keys,outcomes,callId,meta,updatedAt
+255745051250,DONE,ANSWERED,1,28,1234,,fff4aa3f-8ed5-44e6-947c-c82bb3628c41,"{""said"": ""After five minutes, baada ya dakika tano."", ""scheduledBy"": ""ca10b2f7-…""}",2026-11-13T14:41:34.763432Z
```

---

# M. OTP (a code by voice)

## M1. Call a code to a number
**Purpose**: Read a one-time code to a person ("Namba yako ya siri ni moja, mbili, tatu, nne"), e.g. when SMS fails.
Not subject to quiet hours. The code is kept encrypted and never shown in timelines or logs.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/otp`

**Access Level**: 🔒 Protected · Bearer API key · feature `OTP`

**Request JSON Sample**:
```json
{
  "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
  "to": "+255745051250",
  "code": "482913",
  "lang": "sw",
  "numberId": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66",
  "meta": { "purpose": "login" }
}
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `accountId` | uuid | Yes | | |
| `to` | string | Yes | Tanzanian number | |
| `code` | string | Yes | Digits only | 4–10 digits |
| `lang` | string | No | `sw` · `en` | default: the account's language |
| `numberId` | uuid | No | Calling from | default: the account's first number |
| `meta` | object | No | Kept on the call | |

**Success Response JSON Sample** (`201 Created`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Calling",
  "action": null,
  "action_time": "2026-11-13T20:15:00.000000000",
  "data": { "callId": "a7c3…", "status": "CALLING" }
}
```
Follow the call with `call.answered` / `call.ended` webhooks (type `OTP`).

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Validation failed",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:05:41.000000000",
  "data": { "code": "must match \"\\d{4,10}\"" }
}
```
`400` "Only Tanzanian numbers can be called" · `400` "This account has no number to call from" ·
`403 FEATURE_OFF` · `429 RATE_LIMITED` "Too many codes to this number; try again later" (`otpPerDestinationPer10Min`).

---

# N. Account stats

## N1. Calls per day, hour, number or type
**Purpose**: A business dashboard: how many calls, how they ended, how long.

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` / `to` | date | No | `YYYY-MM-DD`, account time; at most 400 days | the last 7 days |
| `groupBy` | string | No | `day` · `hour` · `number` · `type` | `day` |

**Success Response JSON Sample** (`groupBy=day`):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Account stats",
  "action": null,
  "action_time": "2026-11-13T20:05:41.000000000",
  "data": [
    { "key": "2026-11-13", "values": { "incoming": 38, "outgoing": 2, "answered": 39, "byScript": 11, "byAgent": 3,
                                        "byAi": 9, "voicemail": 6, "missed": 0, "abandoned": 1, "hungUpEarly": 8, "seconds": 2311.0 } }
  ]
}
```

| `values` (day / hour / number) | Description |
|-------|-------------|
| `incoming` / `outgoing` | Calls in / out |
| `answered` | Answered calls |
| `byScript` / `byAgent` / `byAi` / `voicemail` | Who dealt with them |
| `missed` | Callers who waited for an agent and nobody answered |
| `abandoned` | Callers who hung up while waiting |
| `hungUpEarly` | Callers who hung up in the first seconds |
| `seconds` | Total call seconds |

With `groupBy=type`, `key` is the call type and `values` = `{calls, answered, seconds, pstnSeconds}`:
```json
[ { "key": "BROADCAST", "values": { "calls": 2, "answered": 1, "seconds": 28, "pstnSeconds": 28 } },
  { "key": "INCOMING",  "values": { "calls": 38, "answered": 38, "seconds": 2283, "pstnSeconds": 2283 } } ]
```

**Errors**: `400` "groupBy must be day, hour, number or type".
Agents' stats: [Part 2D](2d-agents.md#i13-agent-stats).

# Part 2D — Agents and live calls

[← Contents](README.md) · Previous: [Part 2C — Calls](2c-calls.md) · Next: [Part 2E — App actions](2e-actions.md)

People who answer calls. Ringmaster keeps only what it needs to ring them: a `ref` (the client app's user id), a
display name, an optional mobile, their departments and their status. How the browser desk works end to end:
[Part 1 §1.7](1-how-it-works.md#17-agents-in-the-browser); the messages the desk receives: [Part 3](3-webhooks.md#33-agent-desk-signals).

All endpoints: 🔒 Protected · Bearer API key. The agent's own buttons go **through the client app's backend**, which
adds `agentRef` after checking who is logged in — the browser never holds the API key.

```
Set up (once)        I1 department · I7 agent · I5 members
Agent's shift        I12 lobby token → join lobby → I10 status AVAILABLE
A caller arrives     (engine rings: "ring" signal in the lobby)
                     J1 accept → join call room · or J2 decline
On the call          J3 hold · J4 resume · J5 transfer · J6 hang up
After the call       WRAP_UP for wrapUpSec → AVAILABLE (or I11 ready)
End of shift         I10 status OFFLINE → leave lobby
```

---

# I. Departments and agents

## I1. Create a department
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/departments`

**Request JSON Sample**:
```json
{
  "name": "Mapokezi",
  "strategy": "RING_ALL",
  "ringTimeoutSec": 20,
  "wrapUpSec": 30,
  "maxMissedBeforeAway": 5,
  "ifNobodyOnline": "WAIT",
  "record": false
}
```

| Parameter | Type | Required | Description | Validation / default |
|-----------|------|----------|-------------|------------|
| `name` | string | Yes | Shown to callers' agents and in reports | not blank |
| `strategy` | string | No | `RING_ALL` (every free member at once) · `ROUND_ROBIN` (one at a time, in member order, starting after the last one rung) | `RING_ALL` |
| `ringTimeoutSec` | integer | No | How long one ring lasts before the next try | 10–60, default 20 |
| `wrapUpSec` | integer | No | Quiet time after each call before the agent is rung again | 0–300, default 0 |
| `maxMissedBeforeAway` | integer | No | Missed or declined rings in a row before the agent is set `AWAY`; `0` = never | 0–20, default 5 |
| `ifNobodyOnline` | string | No | `WAIT` (caller waits in the waiting room) · `EXIT_NO_ANSWER` (the Connect block's `noAnswer` exit at once) | `WAIT` |
| `record` | boolean | No | Record talk in this department (when the account's recording mode allows) | `true` |

**Success Response JSON Sample** (`201 Created`, `message` "Department created"):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Department created",
  "action": null,
  "action_time": "2026-11-13T20:10:47.69580505",
  "data": {
    "id": "c09c1657-d18a-4a32-94c7-4ffa24e038c3",
    "name": "Mapokezi",
    "strategy": "RING_ALL",
    "ringTimeoutSec": 20,
    "wrapUpSec": 0,
    "maxMissedBeforeAway": 5,
    "ifNobodyOnline": "WAIT",
    "record": false,
    "members": [],
    "waiting": 0
  }
}
```

| Field | Description |
|-------|-------------|
| `id` | Put it in a flow's Connect block: `"department": "<id>"` |
| `members` | Agent refs in ring order |
| `waiting` | Callers waiting now |

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Validation failed",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:10:48.000000000",
  "data": { "ringTimeoutSec": "must be greater than or equal to 10" }
}
```

---

## I2. List departments
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/departments`

**Success Response**: `data` = array of departments ([I1](#i1-create-a-department) shape), by name.

## I3. Update a department
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/departments/{departmentId}`

**Request**: same as [I1](#i1-create-a-department); `name` is required, missing settings keep their value.
**Success Response**: the department, `message` "Department updated". **Errors**: `404` "Department not found".

## I4. Delete a department
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/departments/{departmentId}`

**Success Response**: `200`, `message` "Department deleted", `data` null. Its members are removed from it.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "Department is used by live script versions",
  "code": "DEPARTMENT_IN_USE",
  "action_time": "2026-11-13T20:11:00.000000000",
  "data": { "usedBy": ["recorded-music v2"] }
}
```
`409 DEPARTMENT_BUSY` "Callers are still waiting in this department" — try again when the queue is empty.

---

## I5. Set a department's members
**Purpose**: Replace the whole member list; the order is the round-robin order. Callers already waiting are offered to
new free members at once.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/departments/{departmentId}/members`

**Request JSON Sample**:
```json
{ "agentRefs": ["asha", "juma", "neema"] }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agentRefs` | string[] | Yes | Existing agents of the account; `[]` empties the department; repeats are ignored |

**Success Response**: the department, `message` "Members set".

**Errors**: `404` "Agent not found: ghost" — nothing is changed.

---

## I6. Callers waiting in a department
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/departments/{departmentId}/queue`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Waiting callers",
  "action": null,
  "action_time": "2026-11-13T20:10:47.790000000",
  "data": [
    { "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "from": "+255745051250", "position": 1, "waitingSeconds": 27 }
  ]
}
```
Order: callers put back at the head (a failed transfer, an agent who dropped) first, then by waiting time.

---

## I7. Add or update an agent
**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/agents/{ref}`

**Request JSON Sample**:
```json
{ "displayName": "Asha (mapokezi)", "mobile": "+255712345678" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `{ref}` (path) | string | Yes | The client app's user id | |
| `displayName` | string | Yes | Shown in the desk, wallboard and reports | not blank |
| `mobile` | string | No | Rung when the number's ring mode includes `MOBILE`; `""` removes it; missing keeps it | a phone number |

**Success Response JSON Sample** (`message` "Agent saved"; a new agent starts `OFFLINE`):
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Agent saved",
  "action": null,
  "action_time": "2026-11-13T20:10:47.726488473",
  "data": {
    "ref": "asha",
    "displayName": "Asha (mapokezi)",
    "mobile": null,
    "status": "AVAILABLE",
    "online": true,
    "missedInRow": 0,
    "currentCallId": null,
    "departments": ["c09c1657-d18a-4a32-94c7-4ffa24e038c3"]
  }
}
```

| Field | Description |
|-------|-------------|
| `status` | `OFFLINE` · `AVAILABLE` · `RINGING` · `BUSY` · `WRAP_UP` · `AWAY` ([Part 1 §1.7](1-how-it-works.md#17-agents-in-the-browser)) |
| `online` | The agent's browser is connected to the lobby now |
| `missedInRow` | Missed or declined rings since the last answered one |
| `currentCallId` | The call ringing or live with them |
| `departments` | Department ids they are a member of |

**Errors**: `422` `{"displayName": "must not be blank"}` · `400` "mobile: …" (not a phone number).

---

## I8. List agents
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents`

**Success Response**: `data` = array of agents ([I7](#i7-add-or-update-an-agent) shape), by display name. Use it for a
wallboard together with `agent.status` webhooks.

## I9. Remove an agent
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/agents/{ref}`

**Success Response**: `200`, `message` "Agent removed". They leave every department and are never rung again; a call
in progress with them finishes normally. **Errors**: `404` "Agent not found: …".

---

## I10. Set an agent's status
**Purpose**: The agent's own buttons: **Available**, **Away** (break), **Offline** (end of shift). `RINGING`, `BUSY`
and `WRAP_UP` are set by calls, never by hand.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/accounts/{accountId}/agents/{ref}/status`

**Request JSON Sample**:
```json
{ "status": "AVAILABLE" }
```

| `status` | Effect |
|---|---|
| `AVAILABLE` | Can be rung; `missedInRow` back to 0; a caller already waiting is offered at once |
| `AWAY` | Online but not rung (a ring in progress is cancelled) |
| `OFFLINE` | Not rung |

**Success Response**: the agent, `message` "Status set". An `agent.status` webhook follows.

**Errors**:
- `400` "Status can be set to AVAILABLE, AWAY or OFFLINE"
- `409 AGENT_BUSY` "Finish the current call first"

> Being **online** (connected to the lobby) is separate from the status: an agent whose browser is gone for more than
> 30 s (and who is not on a call) is set `OFFLINE` by the engine.

---

## I11. Ready (end wrap-up early)
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/agents/{ref}/ready`

**Success Response**: the agent (`AVAILABLE` if they were in `WRAP_UP`), `message` "Ready".

---

## I12. Lobby token
**Purpose**: Lets the agent's browser join their **lobby room**, where the engine sends ring signals. Data only: no
microphone. Valid for 10 minutes; ask for a new one before it runs out (an open connection stays up).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/agents/{ref}/lobby-token`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Lobby token",
  "action": null,
  "action_time": "2026-11-13T20:10:47.815393198",
  "data": {
    "url": "wss://<livekit-host>",
    "room": "lobby-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd",
    "identity": "agent-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd:web-e2dc8c22",
    "token": "eyJhbGciOiJIUzI1NiIsInR5…"
  }
}
```

In the browser (LiveKit JS SDK):
```js
const room = new LivekitClient.Room();
room.on(LivekitClient.RoomEvent.DataReceived, bytes => onSignal(JSON.parse(new TextDecoder().decode(bytes))));
await room.connect(data.url, data.token);           // then PUT …/status {AVAILABLE} through the backend
```
Make the browser's ring sound start from the agent's "Start shift" click: browsers mute sound that did not start from
a click.

Each token has **its own identity** (`agent-{id}:web-{random}`), so a second tab, or the browser next to
[Agent Phone](2f-agent-phone.md) (`…:dev-{deviceId}`), never pushes the first one out. The agent is **online while any
of their connections is in the lobby**; the 30 s grace starts when the last one leaves.

---

## I13. Agent stats
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents/stats`

| Query | Type | Required | Description | Default |
|-----------|------|----------|-------------|---------|
| `from` / `to` | date | No | `YYYY-MM-DD`, account time | the last 7 days |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Agent stats",
  "action": null,
  "action_time": "2026-11-13T20:10:47.759658701",
  "data": [
    { "key": "asha", "values": { "offered": 7, "answered": 3, "missed": 1, "declined": 3, "declinedWithCustomer": 2,
                                 "declinedBreak": 1, "declinedWrongDepartment": 0, "declinedOther": 0, "talkSeconds": 50 } }
  ]
}
```

| `values` | Description |
|-------|-------------|
| `offered` | Rings |
| `answered` | Accepted |
| `missed` | Rang until the ring time ran out |
| `declined` | Pressed Decline (all reasons, and declines without a reason) |
| `declinedWithCustomer` · `declinedBreak` · `declinedWrongDepartment` · `declinedOther` | Declines by the reason the agent tapped ([J2](#j2-decline-a-ringing-call)) |
| `talkSeconds` | Time talking to callers |

---

## I14. The agent's desk
**Purpose**: Everything an agent's device shows, in one call: status, departments with their waiting callers and
team, today's numbers, recent calls, the business's lines and the [Agent Phone switches](2a-setup.md). Fetch it on
start, on reconnect and when a [`config` signal](3-webhooks.md#33-agent-desk-signals) arrives; between those the
`status`, `team` and `queue` signals keep it live. Agent Phone gets the same through [L3](2f-agent-phone.md#l3-the-desk-everything-the-app-shows).

**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents/{ref}/desk`

**Success Response JSON Sample**:
```json
"data": {
  "agent": { "ref": "asha", "name": "Asha", "status": "AVAILABLE", "online": true, "since": "2026-11-14T07:53:57Z" },
  "settingsVersion": 3,
  "business": "Ringmaster Lab",
  "privacy": { "showTeam": true, "showCallerNumbers": true, "allowDecline": true, "allowCallBack": true, "allowDialPad": true },
  "lines": [ { "id": "8a8d9c13-bda2-4a18-8410-c5ec49ed2c66", "e164": "+255677000001", "label": null } ],
  "departments": [ {
    "id": "c09c1657-d18a-4a32-94c7-4ffa24e038c3", "name": "Mapokezi", "ringTimeoutSec": 20, "wrapUpSec": 0,
    "waiting": 1, "oldestSince": "2026-11-14T08:17:47Z",
    "callers": [ { "callId": "5d382db0-…", "position": 1, "from": "+255745051250", "name": "John Mushi", "since": "2026-11-14T08:17:47Z" } ],
    "team": [ { "ref": "neema", "name": "Neema", "status": "BUSY", "online": true } ]
  } ],
  "today": { "answered": 9, "missed": 0, "declined": 1, "talkSec": 146 },
  "recent": [ { "callId": "fb40f1bf-…", "direction": "INBOUND", "number": "+255745051250", "name": "John Mushi",
                "at": "2026-11-14T08:17:21Z", "seconds": 56, "result": "HANDLED_BY_AGENT" } ]
}
```
| Field | Description |
|---|---|
| `departments[].callers` | Up to 10 waiting callers in order; `name` when the [contacts URL](3-webhooks.md#34-the-contacts-url) knew them |
| `departments[].team` | Other members; `null` when the account hides the team (`showTeam: false`) |
| `recent` | The agent's last 10 calls (in and out) |
| numbers | Masked as `+2557•••••250` everywhere when `showCallerNumbers` is `false` |

## I15. Search for the dial pad
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents/{ref}/desk/search?digits=john`

Up to 20 matches: first the client app's contacts (asked through its [contacts URL](3-webhooks.md#34-the-contacts-url),
by name or number), then numbers from this agent's and their departments' calls.
```json
"data": [ { "number": "+255745051250", "name": "John Mushi", "label": "VIP customer", "callId": "fb40f1bf-…",
             "direction": "INBOUND", "at": "2026-11-14T08:17:21Z", "calls": 22 } ]
```
`callId` is the latest call with that number (use [J8](#j8-call-someone-back)); `null` for a contact never called.
When numbers are hidden, masked numbers match only on the digits the agent can see, and contacts are left out.

---

# J. Live call controls

Every control takes the agent: `{"agentRef": "asha"}` (sent by the client app's backend). Controls for a call that is
not ringing for, or not live with, that agent are refused.

## J1. Accept a ringing call
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/accept`

**Request JSON Sample**:
```json
{ "agentRef": "asha" }
```

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Call accepted",
  "action": null,
  "action_time": "2026-11-13T19:43:46.998000000",
  "data": {
    "url": "wss://<livekit-host>",
    "room": "call-_+255745051250_uqL6Uaw9VmC9",
    "identity": "agent-0ea57aeb-c3c9-48f5-b366-7fe3e457ffbd",
    "token": "eyJhbGciOiJIUzI1NiIsInR5…"
  }
}
```
The browser joins `room` with `token` and turns the microphone on; the caller's waiting music stops and they are
connected as soon as the agent is in the room (the engine waits up to 15 s; if the agent never arrives, the caller goes
back to the head of the queue). Other agents rung for the same call get `ring_cancel` (`answered_elsewhere`).

**Errors**:
- `409 NOT_RINGING` "This call is not ringing for you any more" (ring time ran out, the caller left, or it was cancelled)
- `409 ALREADY_TAKEN` "Another agent already answered this call"
- `404` "Call not found" · "Agent not found: …"

---

## J2. Decline a ringing call
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/decline`

**Request**: `{"agentRef": "asha", "reason": "WITH_CUSTOMER"}`. `reason` is optional: `WITH_CUSTOMER`, `BREAK` (also
sets the agent `AWAY`), `WRONG_DEPARTMENT`, `OTHER`.

**Success Response**: `200`, `message` "Call declined". The engine rings the next agent; this agent is not rung for this
call again for 30 s. If no other agent could take it (reachable, not `AWAY`/`OFFLINE`, not declined), the caller leaves
the queue at once by the Connect block's `noAnswer` exit. The decline counts as a missed ring
([I1](#i1-create-a-department) `maxMissedBeforeAway`) and sends [`call.ring_declined`](3-webhooks.md#callring_declined).

**Errors**: `409 NOT_RINGING` · `409 DECLINE_NOT_ALLOWED` (the account turned `agentPhone.allowDecline` off).

---

## J3. Hold
**Purpose**: The caller hears hold music; the agent's microphone is muted for the caller.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/hold`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "On hold". Holding twice is harmless.

**Errors**:
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "This call is not with you",
  "code": "NOT_YOUR_CALL",
  "action_time": "2026-11-13T20:10:57.000000000",
  "data": null
}
```

## J4. Resume from hold
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/unhold`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "Back from hold". **Errors**: `409 NOT_YOUR_CALL`.

---

## J5. Transfer
**Purpose**: Hand the caller to another department or a named agent (blind transfer: the first agent leaves at once,
the caller hears music meanwhile).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/transfer`

**Request JSON Samples**:
```json
{ "agentRef": "asha", "toDepartmentId": "dbd644af-e460-40fa-9b09-4cb838915bdc" }
```
```json
{ "agentRef": "asha", "toAgentRef": "juma" }
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `agentRef` | string | Yes | The agent now on the call |
| `toDepartmentId` | uuid | One of the two | The caller goes to the **head** of that department's queue |
| `toAgentRef` | string | One of the two | That agent is rung; if they don't answer, the caller's current department picks them up (they are at its head) |

**Success Response**: `200`, `message` "Transferring". The call's timeline gets a `TRANSFER` event (`{from, to}`).

**Errors**: `400` "Give toDepartmentId or toAgentRef" · `409 NOT_YOUR_CALL` · `404` "Agent not found: …".

---

## J6. Hang up
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/hangup`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, `message` "Hanging up". The caller is disconnected; the
call ends with `result: HANDLED_BY_AGENT`, `endReason: AGENT_HUNG_UP`, `endedBy: AGENT`; the agent goes to `WRAP_UP`
(or straight to `AVAILABLE` when `wrapUpSec` is 0).

**Errors**: `409 NOT_YOUR_CALL`.

On an **outgoing** call that still rings at the other end, hang up **cancels the dial**: the other phone stops ringing.

> **If the agent's browser drops** during a call (closed tab, lost network), the caller is not cut off: they hear music
> and go back to the head of the queue.

---

## J7. Join an outgoing call
**Purpose**: An agent call ([click-to-call](2c-calls.md)) started anywhere (your dashboard, Agent Phone's dial pad or a
call back) sends `outgoing` to the agent's devices; the device then joins with this.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/join`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `200`, the call room token (`url`, `room`, `identity`,
`token`). Follow `call_answered`, `call_recording` and `call_ended` ([3.3](3-webhooks.md#33-agent-desk-signals)):
the other phone's audio arrives while it still rings, so never treat audio as "answered".

**Errors**: `409 NOT_YOUR_CALL` · `409 CALL_ENDED`.

## J8. Call someone back
**Purpose**: Call the other person of an earlier call (a caller, or someone called before) **from the same business
line**. It works even when numbers are hidden from agents, because the agent never needs the number.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/call-back`

**Request**: `{"agentRef": "asha"}`. **Success Response**: `201`, like click-to-call (`callId`, `room`); the agent's
devices get `outgoing`.

**Errors**: `409 CALL_BACK_OFF` (the account turned `agentPhone.allowCallBack` off) · `409 NO_LINE` · the
click-to-call errors (`AGENT_BUSY`, `CAN_CALL_REFUSED`).

## J9. Take a waiting caller
**Purpose**: Answer a caller from the queue list **now**, even while the agent is `AWAY` (they see someone waiting and
pick them up). Only callers waiting for one of the agent's departments, and not while the agent is on a call or
ringing. Other agents' rings for that caller stop; the caller leaves the queue; then it is exactly like
[J1](#j1-accept-a-ringing-call) (the answer is the call room token).

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/take`

**Request**: `{"agentRef": "asha"}`. A `TAKEN_FROM_QUEUE` event goes on the timeline.

**Errors**: `409 NOT_WAITING` · `409 NOT_YOUR_DEPARTMENT` · `409 AGENT_BUSY`.

# Part 2E — App actions and opt-outs

[← Contents](README.md) · Previous: [Part 2D — Agents](2d-agents.md) · Next: [Part 3 — What the engine sends you](3-webhooks.md)

All endpoints: 🔒 Protected · Bearer API key.

---

# K. App actions

The engine never sends SMS, takes payments or tags contacts. The **client app** says what it can do (its *actions*),
flows ask for them (App action block, AI Agent tools, a campaign's fallback), and the client app does the work and
answers. The round trip: [Part 1 §1.9](1-how-it-works.md#19-app-actions-the-client-app-does-something-during-a-call).

```
K1  PUT /v1/actions                          register the list (once, and when it changes)
    flow reaches an App action block   ──►   webhook action.requested {actionId, name, callId, params, wait, timeoutAt}
    client app does it (SMS, M-Pesa push…)
K3  POST /v1/calls/{callId}/action-result    {actionId, outcome, data}   ──► the flow goes on through that outcome's exit
```

## K1. Register the actions
**Purpose**: Replace the client app's whole action list. Flow editors show these as the choices of the App action block.

**Endpoint**: <span style="background-color: #ffc107; color: black; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">PUT</span> `{base_url}/actions`

**Request JSON Sample**:
```json
[
  { "name": "send_sms", "label": "Send SMS",
    "params": { "to":   { "type": "phone", "default": "{caller}" },
                "text": { "type": "text", "max": 480 } },
    "wait": false },

  { "name": "tag_contact", "label": "Tag contact",
    "params": { "tag": { "type": "text", "max": 40 } },
    "wait": false },

  { "name": "pay", "label": "Pay now",
    "params": { "amount": { "type": "money" },
                "phone":  { "type": "phone", "default": "{caller}" },
                "wallet": { "type": "choice", "values": ["M-Pesa", "Tigo Pesa", "Airtel Money", "HaloPesa"] } },
    "wait": true, "outcomes": ["paid", "failed_pin", "cancelled"], "timeoutSec": 120,
    "hold": "Payment", "countAs": { "paid": "paid" }, "risky": true }
]
```

| Field | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `name` | string | Yes | Id used in flows (`"action": "pay"`) | `[a-z][a-z0-9_]{0,63}`, unique |
| `label` | string | Yes | Shown in the editor | not blank, max 120 |
| `params` | object | No | `{paramName: {type, default?, max?, values?, optional?}}` | name `[a-zA-Z][a-zA-Z0-9_]{0,39}` |
| `params.*.type` | string | Yes | `text` · `phone` · `money` · `number` · `choice` | `choice` needs `values` |
| `params.*.default` | string | No | Used when the flow does not set it; may be a template (`{caller}`) | |
| `params.*.max` | integer | No | `text`: longest after variables are filled | |
| `wait` | boolean | No | `true`: the caller waits (music, waiting room) until the client app answers or `timeoutSec` passes | default `false` |
| `outcomes` | string[] | When `wait` | The answers the client app can give; each becomes an exit of the block | `[a-z][a-z0-9_]{0,39}`; not `next`, `failed`, `timeout` (always there) |
| `timeoutSec` | integer | No | Longest wait for the answer | 10–1800, default 120 |
| `hold` | string | No | Waiting room preset while waiting (e.g. `Payment`) | |
| `countAs` | object | No | Outcome → call outcome tag, for reports (`{"paid": "paid"}`) | |
| `risky` | boolean | No | A flow using it needs approval (listed in `riskyBlocks`) | default `false` |

At most 50 actions.

**How flows fill the parameters**:

| Type | Value in the flow | Checked |
|---|---|---|
| `text` | template: `"Habari {name}, oda yako iko tayari"` | `max` length after filling |
| `phone` | template or E.164 (`"{caller}"`) | E.164 after filling |
| `money` | template or number (`"{amount}"`) | digits after filling (separators removed) |
| `number` | template or number | numeric |
| `choice` | one of `values` | when the version is pushed |

A parameter without a `default` and not marked `optional` must be set by the flow, or the version is refused (422).

**Success Response**: `200`, `message` "Actions saved", `data` = the list as stored ([K2](#k2-read-the-actions)).

**Errors**:
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "The action list is not valid",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:13:33.950000000",
  "data": [
    "pay: an action that waits needs outcomes",
    "pay: timeoutSec must be 10-1800",
    "pay.wallet: a choice needs values",
    "Duplicate action: pay"
  ]
}
```
```json
{
  "success": false,
  "httpStatus": "422 UNPROCESSABLE_CONTENT",
  "message": "Validation failed",
  "code": "VALIDATION_FAILED",
  "action_time": "2026-11-13T20:14:41.000000000",
  "data": { "[0].label": "must not be blank", "[0].name": "must match \"[a-z][a-z0-9_]{0,63}\"" }
}
```
```json
{
  "success": false,
  "httpStatus": "409 CONFLICT",
  "message": "An action that is in use can't be removed or narrowed",
  "code": "ACTION_IN_USE",
  "action_time": "2026-11-13T20:15:00.000000000",
  "data": [
    { "script": "orders", "version": 3, "action": "pay" },
    { "callout": "4b0e…", "action": "send_sms" }
  ]
}
```
`ACTION_IN_USE`: removing an action, dropping an outcome, or changing `wait`, while a runnable flow version (App action
block or AI tool) or a scheduled / running / paused call-out (fallback) uses it. Adding actions, outcomes or optional
parameters is always allowed.

---

## K2. Read the actions
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/actions`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Actions",
  "action": null,
  "action_time": "2026-11-13T20:15:10.000000000",
  "data": [
    { "name": "send_sms", "label": "Send SMS",
      "params": { "to": { "type": "phone", "default": "{caller}" }, "text": { "type": "text", "max": 480 } },
      "wait": false, "outcomes": null, "timeoutSec": 120, "hold": null, "countAs": null, "risky": false },
    { "name": "pay", "label": "Pay now",
      "params": { "amount": { "type": "money" }, "phone": { "type": "phone", "default": "{caller}" },
                  "wallet": { "type": "choice", "values": ["M-Pesa", "Tigo Pesa", "Airtel Money", "HaloPesa"] } },
      "wait": true, "outcomes": ["paid", "failed_pin", "cancelled"], "timeoutSec": 120, "hold": "Payment",
      "countAs": { "paid": "paid" }, "risky": true }
  ]
}
```
Each client app sees only its own list; all its accounts' flows may use it.

---

## K3. Answer an action
**Purpose**: Tell the engine how an `action.requested` turned out. For a waiting action the caller hears the result at
once and the flow goes on through that outcome's exit.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/calls/{callId}/action-result`

**What the client app received** (webhook, [Part 3](3-webhooks.md#actionrequested)):
```json
{ "id": "…", "type": "action.requested", "createdAt": "2026-11-20T09:12:30Z",
  "data": { "actionId": "c3a9e1f0-…", "name": "pay", "callId": "4b1e…", "accountRef": "duka-juma", "block": "pay_1",
            "params": { "amount": "45000", "phone": "+255712345678", "wallet": "M-Pesa" },
            "wait": true, "timeoutAt": "2026-11-20T09:14:30Z" } }
```

**Request JSON Sample**:
```json
{
  "actionId": "c3a9e1f0-…",
  "outcome": "paid",
  "data": { "receipt": "QK7HT2LM9P", "amount": "45000" }
}
```

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `actionId` | uuid | Yes | From `action.requested` |
| `outcome` | string | Yes | Waiting action: one of its `outcomes`, or `failed`. Not waiting: `sent`, `done`, `ok` or `failed` (only for the call's timeline) |
| `data` | object | No | Saved on the call as `{action.<block>.<key>}` variables: the flow can say "Risiti yako ni {action.pay_1.receipt}" |

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Action result recorded",
  "action": null,
  "action_time": "2026-11-20T09:13:05.000000000",
  "data": {
    "actionId": "c3a9e1f0-…",
    "callId": "4b1e…",
    "name": "pay",
    "outcome": "paid",
    "callWaiting": true,
    "resolvedAt": "2026-11-20T09:13:05Z"
  }
}
```

| Field | Description |
|-------|-------------|
| `callWaiting` | The caller was still waiting for this answer, and now goes on. `false` = the call had already moved on (or ended): the answer is recorded and sent back as a `call.block` webhook with `late: true` |

**Answering twice**: the same outcome again → `200` with the same `data` (safe to retry). A different outcome → `409`.

**Errors**:
- `400` "outcome is required" · "outcome must be one of [failed, paid, failed_pin, cancelled]"
- `404` "Call not found" · "Action not found on this call"
- `409 ACTION_ALREADY_RESOLVED` "This action already has the result paid"
- `409 ACTION_TIMED_OUT` — `timeoutSec` passed; the call already took the `timeout` exit

> If a flow names an action the client app no longer has, the block takes its `failed` exit and the timeline shows
> `ERROR {code: ACTION_UNKNOWN}`.

---

# L. Opt-outs (do-not-call list)

Numbers an account must never call. The engine checks the list when people are added to a call-out, again just
before each dial, and when a caller books a callback. Callers can put themselves on it with the **Opt out** block
("Bonyeza 9 tusikupigie tena").

## L1. Add a number
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/opt-outs`

**Request JSON Sample**:
```json
{ "e164": "+255745051250" }
```

| Parameter | Type | Required | Description | Validation |
|-----------|------|----------|-------------|------------|
| `e164` | string | Yes | The number | full E.164 with `+` (`0745…` is refused) |

**Success Response**: `200`, `message` "Number opted out", `data` null. Adding it again is harmless.

**Errors**: `400` "Not an E.164 number: 0700000099".

## L2. List
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/opt-outs`

**Success Response JSON Sample**:
```json
{
  "success": true,
  "httpStatus": "200 OK",
  "message": "Opted-out numbers",
  "action": null,
  "action_time": "2026-11-13T20:14:50.957455654",
  "data": ["+255700000099"]
}
```

## L3. Remove a number
**Purpose**: The person may be called again (they asked to be, or it was a mistake).

**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/opt-outs/{e164}`

Encode the `+` in the path: `/opt-outs/%2B255745051250`.

**Success Response**: `200`, `message` "Number can be called again", `data` null. Removing a number that is not on the
list is harmless.

# Part 2F — Agent Phone (devices and their own API)

Ringmaster Agent Phone is the desktop app agents keep running to pick up calls ([AGENT-PHONE.md](../AGENT-PHONE.md)).
This part has two sides:

- **K. For the client app (API key):** connect an agent's Agent Phone with a one-time code, list their devices, remove
  one. This is the "Connect Agent Phone" button on your dashboard.
- **L. Agent Phone's own API (device token):** what the app itself calls. You only need it to build your own app;
  Ringmaster's Agent Phone already does all of it.

Who does what:

| Job | Who |
|---|---|
| Who the person is (password, 2FA, staff accounts) | **Your app.** Ask for a pairing code only after your own login |
| May this computer take this agent's calls (keys, tokens, revoke) | **Ringmaster.** The engine checks every accept, decline and hang-up |

```
Agent signed in to your dashboard → "Connect Agent Phone"
  your backend ── POST /v1/accounts/{acc}/agents/{ref}/devices/pairings ──► engine
              ◄── { code: "482913", link: "ringmaster-agent://pair?code=482913&host=…", expiresAt } ──
  show the code (and the link) → the agent types the code into Agent Phone
Agent Phone: makes a P-256 key (kept in the OS password vault)
  POST /v1/agent/pair { code, deviceName, platform } + DPoP proof (signed with that key)
  ◄── { deviceId, accessToken (15 min), refreshToken }
```

---

# K. Devices (client app, API key)

## K1. A pairing code
**Purpose**: A one-time code for one agent. It works **once**, for **5 minutes**. Pairing tries are limited per IP.

**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/accounts/{accountId}/agents/{ref}/devices/pairings`

**Success Response JSON Sample** (`201`):
```json
{
  "success": true,
  "httpStatus": "201 CREATED",
  "message": "Pairing code",
  "action": null,
  "action_time": "2026-11-14T14:20:02.118",
  "data": {
    "code": "078183",
    "link": "ringmaster-agent://pair?code=078183&host=https%3A%2F%2Fapi.ringmaster.example",
    "expiresAt": "2026-11-14T11:25:02.101Z"
  }
}
```
Show both: the **code** to type, and the **link** as a button (it opens Agent Phone where links are set up).

**Errors**: `404` "Agent not found: …".

## K2. List an agent's devices
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/accounts/{accountId}/agents/{ref}/devices`

```json
"data": [
  { "id": "1400d27e-8e21-4f9c-be7d-8e7c77c5f660", "name": "Agent Phone on reception-pc", "platform": "linux",
    "appVersion": "0.1.0", "pairedAt": "2026-11-14T11:24:05Z", "lastSeenAt": "2026-11-14T11:24:13Z", "lastIp": "192.168.100.215" }
]
```
At most **5** devices per agent; pairing a sixth answers `409 TOO_MANY_DEVICES`.

## K3. Remove a device
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/accounts/{accountId}/agents/{ref}/devices/{deviceId}`

**Success Response**: `200`, `message` "Device removed". The device is cut off **at once**: its tokens stop working, it
is taken out of the lobby, it receives `device_revoked` and goes back to its pairing screen. You get
[`agent.device_revoked`](3-webhooks.md#agentdevice_paired--agentdevice_revoked).

A device is also revoked when: the agent is removed, its old refresh token is used again (copied: reason
`TOKEN_REUSED`), it was not used for 30 days (`UNUSED`), or the agent signs out on it (`SIGNED_OUT`).

## K4. Shared computers (stations)
A **station** is a computer the business shares (the reception PC). It belongs to no agent and rings for nobody until
an agent starts a shift on it **from your dashboard**, after signing in to your app: your login is the proof of who they
are. The computer switches by itself (it waits in its own room, `station-{id}`). One agent at a time; starting a shift
while someone else is on replaces them (never during a call: `409 SHIFT_BUSY`).

| Method | Path | Does |
|---|---|---|
| POST | `/accounts/{accountId}/stations/pairings` | A one-time code for a new shared computer (same shape as [K1](#k1-a-pairing-code)) |
| GET | `/accounts/{accountId}/stations` | Stations and who is on shift: `id`, `name`, `platform`, `onShiftRef`, `onShiftName`, `shiftSince`, `idleEndMin`, `endAtTime`, `lastSeenAt` |
| PATCH | `/accounts/{accountId}/stations/{stationId}` | `{"idleEndMin": 120, "endAtTime": "17:30"}`: end the shift after that many minutes without calls (0 = never), and at that time of day (`""` = never) |
| POST | `/accounts/{accountId}/stations/{stationId}/shift` | `{"agentRef": "asha"}`: start the shift ("Start my shift on Reception PC") |
| DELETE | `/accounts/{accountId}/stations/{stationId}/shift` | End the shift now |
| DELETE | `/accounts/{accountId}/stations/{stationId}` | Remove the station (signed out at once) |
| PUT | `/accounts/{accountId}/agents/{ref}/pin` | Optional: `{"pin": "4321"}`, 4–6 digits, stored hashed (`""` removes it) |

**PINs are optional** and Ringmaster's Agent Phone doesn't ask for them: a shift starts from the dashboard. They exist
for a client app that builds its own station screen ([L7](#l7-shifts-stations-only)); five wrong PINs lock the agent
out for 15 minutes (`409 PIN_LOCKED`). Webhooks: `station.paired`,
`station.removed`, [`agent.shift_started` · `agent.shift_ended`](3-webhooks.md#agentshift_started--agentshift_ended).

---

# L. Agent Phone's API (device token)

Base: `{base_url}/agent`. Every call except L1 and L2 sends `Authorization: Bearer <accessToken>`. A device token
works **only** here (never on `/v1/accounts/…`), and only for its own agent: there is no `agentRef` in these requests.

`401 DEVICE_UNAUTHORIZED` means the token expired, or the device was removed: refresh once; if that fails too, go back
to the pairing screen.

## DPoP proofs
L1 and L2 carry a header `DPoP: <proof>` (RFC 9449): a compact JWS signed with the device's key.

| Part | Content |
|---|---|
| header | `{"typ": "dpop+jwt", "alg": "ES256", "jwk": {"kty": "EC", "crv": "P-256", "x": "…", "y": "…"}}` |
| claims | `{"htm": "POST", "htu": "<full URL of this request>", "iat": <unix seconds>, "jti": "<random, ≥ 12 chars>"}` |
| signature | ES256 over `base64url(header).base64url(claims)`, as raw R‖S (64 bytes), base64url |

Proofs older than 60 s, or seen before (same `jti`), are refused. On refresh the key must be the one used at pairing.

## L1. Pair
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/agent/pair` (no token; DPoP proof)

**Request**:
```json
{ "code": "078183", "deviceName": "Agent Phone on reception-pc", "platform": "linux", "appVersion": "0.1.0" }
```

**Success Response** (`201`):
```json
"data": { "deviceId": "1400d27e-8e21-4f9c-be7d-8e7c77c5f660", "accessToken": "rma_…", "expiresIn": 900,
          "refreshToken": "rmr_…", "agentRef": "asha", "agentName": "Asha", "business": "Ringmaster Lab" }
```
Keep the private key and the refresh token in the OS password vault; keep the access token in memory only.

**Errors**: `401` "This code is wrong or has expired…" · `401` DPoP errors · `409 TOO_MANY_DEVICES` · `429` too many tries.

## L2. Refresh
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/agent/token` (no token; DPoP proof from the same key)

**Request**: `{"refreshToken": "rmr_…"}`. **Success Response**: the same shape as L1, with a **new** refresh token.
Save it before anything else: the old one is dead, and using it again revokes the device.

## L3. The desk (everything the app shows)
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/agent/me`

Same answer as [I14](2d-agents.md#i14-the-agents-desk) for this device's agent.

## L4. Search the dial pad
**Endpoint**: <span style="background-color: #28a745; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">GET</span> `{base_url}/agent/search?digits=0745`

Same as [I15](2d-agents.md#i15-search-for-the-dial-pad): digits match numbers; letters match the client app's
contacts and earlier callers' names.

## L5. Lobby token
**Endpoint**: <span style="background-color: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">POST</span> `{base_url}/agent/lobby-token`

Like [I12](2d-agents.md#i12-lobby-token), with the identity `agent-{agentId}:dev-{deviceId}`.

## L6. Calls
| Method | Path | Does | Same as |
|---|---|---|---|
| POST | `/agent/calls/{callId}/accept` | Accept the ringing call; returns the call room token | [J1](2d-agents.md#j1-accept-a-ringing-call) |
| POST | `/agent/calls/{callId}/decline` | Body `{"reason": "WITH_CUSTOMER"}` (optional) | [J2](2d-agents.md#j2-decline-a-ringing-call) |
| POST | `/agent/calls/{callId}/take` | Answer a waiting caller now, even while away | [J9](2d-agents.md#j9-take-a-waiting-caller) |
| POST | `/agent/available` | "I'm back": `AVAILABLE` again (out of wrap-up: ready). Going away stays on the dashboard | [I10](2d-agents.md#i10-set-an-agents-status) |
| POST | `/agent/calls/{callId}/hangup` | Hang up (cancels the dial if the other phone still rings) | [J6](2d-agents.md#j6-hang-up) |
| POST | `/agent/calls/{callId}/join` | Join an outgoing call that is this agent's (after `outgoing`) | [J7](2d-agents.md#j7-join-an-outgoing-call) |
| POST | `/agent/calls/{callId}/call-back` | Call the other person of an earlier call back, from the same line | [J8](2d-agents.md#j8-call-someone-back) |
| POST | `/agent/calls` | Dial pad: `{"numberId": "…", "to": "0745051250"}` | [Click-to-call](2c-calls.md) with `meta.via: "dial-pad"` |

Pairing with a **station** code (K4) gives `"kind": "STATION"` and `agentRef: null` in L1's answer.

## L7. Shifts (stations only)
A station's token acts for the agent on shift; with nobody on shift, agent endpoints answer `409 NO_SHIFT`.

| Method | Path | Does |
|---|---|---|
| GET | `/agent/station` | `{name, business, onShift: {ref, name, since} \| null, agents: [{ref, name}]}` (agents with a PIN) |
| POST | `/agent/station-token` | Token for the station's own room `station-{id}`, where `shift {state: "started", agentRef, name}` arrives while nobody is on shift |
| POST | `/agent/shift` | Optional PIN start: `{"agentRef": "asha", "pin": "4321"}` (`401` wrong PIN, `409 PIN_LOCKED`, `409 NO_PIN`, `409 SHIFT_BUSY`) |
| DELETE | `/agent/shift` | End my shift |

When a shift ends elsewhere (dashboard, idle time, end of day), the station gets a `shift` signal
`{deviceId, state: "ended", reason}` in the agent's lobby and is taken out of it.

## L8. Sign out
**Endpoint**: <span style="background-color: #dc3545; color: white; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 12px; font-weight: bold;">DELETE</span> `{base_url}/agent/device`

The device is revoked (`SIGNED_OUT`); delete the key and refresh token from the vault.

# Part 3 — What the engine sends you (Webhook)

[← Contents](README.md) · Previous: [Part 2E — App actions](2e-actions.md) · Next: [Part 4 — Reference](4-reference.md)

The engine talks to the client app in three ways:

<table id="bkmrk-channel-direction-us"><thead><tr><th>Channel</th><th>Direction</th><th>Used for</th></tr></thead><tbody><tr><td>**Webhooks**</td><td>engine → client app's webhook URL (POST)</td><td>Everything that happened: calls, blocks, recordings, call-outs, agents, media, app actions</td></tr><tr><td>**The can-call check**</td><td>engine → client app's can-call URL (GET), waits for the answer</td><td>Ask before a call: is this account allowed to call now? (optional)</td></tr><tr><td>**Agent desk signals**</td><td>engine → the agent's browser, through LiveKit</td><td>Ring, ring cancelled, connecting</td></tr></tbody></table>

Both URLs and the shared secret are set by the Ringmaster owner for the client app ([Appendix](appendix.md)).

---

# 3.1 Webhooks

## Every webhook

```http
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: f267e572-f9d2-4a8d-831a-a3bec700cd44
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 5c1e9a0d3f…   (64 hex characters)

{
  "id": "f267e572-f9d2-4a8d-831a-a3bec700cd44",
  "type": "call.ended",
  "createdAt": "2026-11-13T16:59:31.053Z",
  "data": { … }
}

```

<table id="bkmrk-part-description-x-r"><thead><tr><th>Part</th><th>Description</th></tr></thead><tbody><tr><td>`X-Ringmaster-Event`</td><td>Same as `type`</td></tr><tr><td>`X-Ringmaster-Delivery`</td><td>Same as `id`; unique per event, also on retries — **drop repeats by this id**</td></tr><tr><td>`X-Ringmaster-Timestamp`</td><td>Unix seconds when this attempt was sent</td></tr><tr><td>`X-Ringmaster-Signature`</td><td>Lower-case hex HMAC-SHA256 of `"<timestamp>.<raw body>"` with the webhook secret</td></tr><tr><td>`createdAt`</td><td>When the event happened (attempts later keep it)</td></tr><tr><td>`data`</td><td>The event's content (below). Every event about an account carries `accountRef` (the account's `externalRef`) — except `call.block` and `call.scheduled`, which carry `callId`</td></tr></tbody></table>

## Checking the signature

Check it on the **raw body bytes**, before parsing; refuse timestamps more than 5 minutes off.

```python
import hmac, hashlib, time

def valid(secret: str, headers, raw_body: bytes) -> bool:
    ts = headers["X-Ringmaster-Timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers["X-Ringmaster-Signature"])

```

```java
String expected = HexFormat.of().formatHex(Mac.getInstance("HmacSHA256") {{ init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256")); }}
        .doFinal((timestamp + "." + rawBody).getBytes(UTF_8)));
boolean ok = MessageDigest.isEqual(expected.getBytes(UTF_8), signature.getBytes(UTF_8));

```

## Answering, retries, order

- Answer **2xx within a few seconds**, then do the work (queue it). Any other answer, or no answer, is retried: **1 min, 5 min, 30 min, 2 h, 6 h, 12 h**, then every 12 h, until **24 h** after the event; after that the event is marked failed and not sent again.
- Events are stored in the same database step as the change they report, so none is lost when the engine restarts.
- **Order is not guaranteed** across retries: `call.ended` can arrive before a late `call.block`. Use `createdAt`, and treat each event as "the latest I know", not as a step in a strict sequence.
- The same event can arrive twice (a 2xx that got lost on the way back). Keep the `id`s you processed.
- The owner can send a `test.ping` (`ringmaster-admin client-app test-webhook <app>`) to check the URL and the secret.

## Events

<table id="bkmrk-type-when-main-data-"><thead><tr><th>Type</th><th>When</th><th>Main `data`</th></tr></thead><tbody><tr><td>[`call.started`](#callstarted)</td><td>A call begins (incoming picked up, or dialling out)</td><td>callId, type, direction, from, to</td></tr><tr><td>[`call.answered`](#callanswered)</td><td>The person answered a call-out / OTP / agent call</td><td>same as `call.started`</td></tr><tr><td>[`call.block`](#callblock)</td><td>A caller's choice or answer worth acting on (menu, collect, poll, pick, ask, request, app action)</td><td>callId, block, type, …</td></tr><tr><td>[`call.ended`](#callended)</td><td>The call is over</td><td>the full call summary</td></tr><tr><td>[`call.missed`](#callmissed)</td><td>A caller gave up waiting, or nobody answered them</td><td>callId, from, to, result</td></tr><tr><td>[`call.ring_declined`](#callring_declined)</td><td>An agent declined a ring</td><td>callId, agentRef, department, reason, from</td></tr><tr><td>[`call.scheduled`](#callscheduled)</td><td>A caller booked a callback</td><td>callId, calloutId, at, to, said</td></tr><tr><td>[`voicemail.received`](#voicemailreceived--recordingready)</td><td>A voicemail is stored</td><td>callId, recordingId, durationSec, from</td></tr><tr><td>[`recording.ready`](#voicemailreceived--recordingready)</td><td>A recorded live part (agent talk) is stored</td><td>same</td></tr><tr><td>[`summary.ready`](#summaryready)</td><td>Transcript and summary of a recording are ready</td><td>callId, summary, sentiment, language, fields, unanswered</td></tr><tr><td>[`ai.unanswered`](#aiunanswered)</td><td>The AI could not answer some caller questions</td><td>callId, questions</td></tr><tr><td>[`action.requested`](#actionrequested)</td><td>A flow asks the client app to do one of its actions</td><td>actionId, name, callId, params, wait, timeoutAt</td></tr><tr><td>[`request.failed`](#requestfailed)</td><td>A Send request delivery kept failing for 24 h</td><td>callId, block, attempts, status, error</td></tr><tr><td>[`callout.progress`](#calloutprogress--calloutcompleted--calloutpaused)</td><td>Every 30 s while a call-out runs</td><td>counts</td></tr><tr><td>[`callout.paused`](#calloutprogress--calloutcompleted--calloutpaused)</td><td>The engine paused a call-out</td><td>counts + reason</td></tr><tr><td>[`callout.completed`](#calloutprogress--calloutcompleted--calloutpaused)</td><td>Everybody was called (or it was stopped)</td><td>counts</td></tr><tr><td>[`callout.recipient_results`](#calloutrecipient_results)</td><td>Batches of people who are done</td><td>results\[\]</td></tr><tr><td>[`agent.status`](#agentstatus)</td><td>An agent's status or presence changed</td><td>agentRef, status, online, departments</td></tr><tr><td>[`agent.device_paired`](#agentdevice_paired--agentdevice_revoked)</td><td>An agent connected an Agent Phone</td><td>agentRef, deviceId, name, platform</td></tr><tr><td>[`agent.device_revoked`](#agentdevice_paired--agentdevice_revoked)</td><td>A device was removed or cut off</td><td>agentRef, deviceId, name, platform, reason</td></tr><tr><td>[`agent.shift_started`](#agentshift_started--agentshift_ended)</td><td>An agent started a shift on a shared computer</td><td>agentRef, stationId, station</td></tr><tr><td>[`agent.shift_ended`](#agentshift_started--agentshift_ended)</td><td>A shift ended</td><td>agentRef, stationId, station, reason</td></tr><tr><td>`station.paired` · `station.removed`</td><td>A shared computer was added or removed ([2F K4](2f-agent-phone.md#k4-shared-computers-stations))</td><td>stationId, name</td></tr><tr><td>[`media.ready`](#mediaready--mediafailed) / [`media.failed`](#mediaready--mediafailed)</td><td>An uploaded audio file finished processing</td><td>mediaId, durationMs / error</td></tr><tr><td>[`test.ping`](#testping)</td><td>Sent by the owner to test the setup</td><td>—</td></tr></tbody></table>

The samples below are real events, shortened only where marked.

---

### `call.started`

```json
{ "id": "32c49361-fa61-441c-9c6d-9fcb0add6227", "type": "call.started", "createdAt": "2026-11-13T16:58:37.622Z",
  "data": { "callId": "4e2e8a2d-4a1e-487e-b684-bd7e5cfee244", "accountRef": "softphone", "type": "INCOMING",
            "direction": "INBOUND", "from": "+255745051250", "to": "+255677000001" } }

```

Use it for a "live calls" screen or a screen pop (look up `from` in the client app's contacts).

### `call.answered`

```json
{ "id": "c8365f5d-d368-47b9-a6d2-910ba5e9523e", "type": "call.answered", "createdAt": "2026-11-13T14:41:06.810Z",
  "data": { "callId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41", "accountRef": "softphone", "type": "BROADCAST",
            "direction": "OUTBOUND", "from": "+255677000003", "to": "+255745051250" } }

```

Only for outgoing calls (incoming calls are answered by the engine at once).

### `call.block`

Sent when a caller does something the client app may act on during the call. `data.type` says which block:

<table id="bkmrk-type-extra-fields-ex"><thead><tr><th>`type`</th><th>Extra fields</th><th>Example</th></tr></thead><tbody><tr><td>`menu`</td><td>`option` (the option's id), `by` (`key` / `speech`)</td><td>`{"callId": "763f…", "block": "main", "type": "menu", "option": "jitoe", "by": "key"}`</td></tr><tr><td>`collect`</td><td>`saveAs`, `value` (`••••` when the block is `sensitive`)</td><td>`{"block": "acct", "type": "collect", "saveAs": "account", "value": "1234"}`</td></tr><tr><td>`poll`</td><td>`question` (saveAs), `answer` (key), `label`, `by`</td><td>`{"block": "rate", "type": "poll", "question": "rating", "answer": "5", "by": "key"}`</td></tr><tr><td>`pick`</td><td>`id`, `label` of the chosen item</td><td>`{"block": "product", "type": "pick", "id": "acetic", "label": "Acetic Acid"}`</td></tr><tr><td>`ask`</td><td>`saveAs`, `text` (the transcribed answer)</td><td>`{"block": "problem", "type": "ask", "saveAs": "problem", "text": "Simu yangu haiwaki"}`</td></tr><tr><td>`request`</td><td>`outcome` (the exit taken), `status` (HTTP); late callbacks add `late: true`, `reply`</td><td>`{"block": "look", "type": "request", "outcome": "success", "status": 200}`</td></tr><tr><td>`app_action`</td><td>`name`, `outcome`; `late: true` when the answer came after the call moved on</td><td>`{"block": "pay_1", "type": "app_action", "name": "pay", "outcome": "paid"}`</td></tr></tbody></table>

```json
{ "id": "a8156b02-36c6-4484-bb04-8f8b61b723c7", "type": "call.block", "createdAt": "2026-11-13T16:18:05.966Z",
  "data": { "callId": "763f750d-cf31-4265-a03c-7540627d55df", "block": "main", "type": "menu", "option": "jitoe", "by": "key" } }

```

### `call.ended`

`data` is the full call summary, exactly as `GET /v1/calls/{callId}` returns it ([Part 2C, G2](2c-calls.md#g2-get-a-call)).

```json
{ "id": "f267e572-f9d2-4a8d-831a-a3bec700cd44", "type": "call.ended", "createdAt": "2026-11-13T16:59:31.053Z",
  "data": {
    "callId": "4e2e8a2d-4a1e-487e-b684-bd7e5cfee244", "accountRef": "softphone", "accountId": "adb72fac-66a2-471b-a437-035fe008197d",
    "type": "INCOMING", "direction": "INBOUND", "from": "+255745051250", "to": "+255677000001",
    "status": "ENDED", "result": "HANDLED_BY_AGENT", "endReason": "AGENT_HUNG_UP", "endedBy": "AGENT", "handledBy": "AGENT",
    "startedAt": "2026-11-13T16:58:37.599172Z", "answeredAt": "2026-11-13T16:58:37.599172Z", "endedAt": "2026-11-13T16:59:31.034198254Z",
    "durations": { "ringSeconds": null, "totalSeconds": 54, "scriptSeconds": 1, "waitSeconds": 16, "talkSeconds": 35, "holdSeconds": 0, "aiSeconds": 0 },
    "keys": null, "lastBlock": "desk", "outcomes": [],
    "usage": { "pstnSeconds": 54, "trunkSeconds": 54, "waitSeconds": 16, "aiSeconds": 0, "ttsChars": 0, "sttSeconds": 0 },
    "providerCost": { "tzs": 25.61, "usd": 0.0, "items": [ { "provider": "AT", "tzs": 25.61, "usd": 0.0, "lines": [ "…" ] } ] },
    "recorded": false,
    "script": { "versionId": "e183c7f1-3137-4848-95c1-11a7e8bef17a", "version": 1, "ref": "desk-test" },
    "calloutId": null, "hangupCause": null, "sipStatus": null, "meta": null } }

```

`recorded: true` means a `voicemail.received` / `recording.ready` (and later `summary.ready`) is coming or came.

### `call.missed`

A caller waited for an agent and left (`ABANDONED`) or nobody answered within the waiting room's give-up time (`MISSED`). Use it for a "call back" list.

```json
{ "id": "07c440f3-50b1-4918-8338-cfd2b8e17bf2", "type": "call.missed", "createdAt": "2026-11-13T16:28:17.449Z",
  "data": { "callId": "44f41241-e356-46d1-86aa-dcd4f0f82c12", "accountRef": "softphone",
            "from": "+255745051250", "to": "+255677000001", "result": "ABANDONED" } }

```

### `call.ring_declined`

An agent pressed Decline. `reason` is what they tapped (`WITH_CUSTOMER`, `BREAK`, `WRONG_DEPARTMENT`, `OTHER`, or `null`); `from` is masked when the account hides caller numbers. Use it for a live "declined" counter; the daily totals are in [agent stats](2d-agents.md).

```json
{ "id": "0c5d0d0e-6a3e-4c0c-9a51-5b1a0e3f9a10", "type": "call.ring_declined", "createdAt": "2026-11-14T12:05:36.102Z",
  "data": { "callId": "44f41241-e356-46d1-86aa-dcd4f0f82c12", "accountRef": "softphone", "agentRef": "asha",
            "department": "Mapokezi", "reason": "BREAK", "from": "+255745051250" } }

```

### `call.scheduled`

A caller asked to be called later (Schedule call block or the AI's `schedule_call`) and a one-person call-out was booked.

```json
{ "id": "75974f9d-dc08-4fcf-834d-dea89c3bb037", "type": "call.scheduled", "createdAt": "2026-11-13T14:36:38.302Z",
  "data": { "callId": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf", "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
            "at": "2026-11-13T17:41+03:00", "to": "+255745051250", "scriptRef": "account-callback",
            "said": "After five minutes, baada ya dakika tano." } }

```

<table id="bkmrk-field-description-at"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`at`</td><td>Booked time, with the account's offset</td></tr><tr><td>`scriptRef`</td><td>The Outgoing flow that will play</td></tr><tr><td>`said`</td><td>The caller's words (absent for a fixed time)</td></tr><tr><td>`calloutId`</td><td>Show or cancel it with the call-out API ([Part 2C](2c-calls.md#h4-start-pause-resume-stop-cancel))</td></tr></tbody></table>

A new booking by the same caller for the same flow replaces the old one (the old call-out is cancelled).

### `voicemail.received` / `recording.ready`

```json
{ "id": "3a73b6ee-36f4-4c4e-82ef-599145ed63a1", "type": "voicemail.received", "createdAt": "2026-11-13T16:46:20.647Z",
  "data": { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "accountRef": "softphone",
            "recordingId": "3eb839d7-bac0-49da-a870-7aba6fa77b6e", "kind": "VOICEMAIL", "durationSec": 11, "from": "+255745051250" } }

```

`recording.ready` has the same shape with `kind: "CALL"` (the recorded talk with an agent). Fetch the audio with `GET /v1/calls/{callId}/recording`.

### `summary.ready`

```json
{ "id": "0f86f71d-8bec-4aff-a634-2fca99f4c3a0", "type": "summary.ready", "createdAt": "2026-11-13T16:46:27.191Z",
  "data": { "callId": "355760f8-51c0-47c8-b3b0-49c3612e4b96", "accountRef": "softphone", "language": "sw",
            "summary": "Mpigaji amesema huo ndio ujumbe wake na ametoa shukrani nyingi.", "sentiment": "positive",
            "fields": {}, "unanswered": [] } }

```

The full text: `GET /v1/calls/{callId}/transcript`.

### `ai.unanswered`

Questions callers asked an AI Agent that its knowledge did not answer — material for the business's knowledge base.

```json
{ "id": "2099a5dd-ceab-4609-b32b-a167ea91d39e", "type": "ai.unanswered", "createdAt": "2026-11-13T09:30:36.154Z",
  "data": { "callId": "d6f2ef46-2775-4696-8cac-e4e1e9e2133a", "accountRef": "softphone",
            "questions": ["Je, miadi ya kesho saa 1 usiku inaweza kupangwa?", "Amini, wanipi."] } }

```

### `action.requested`

A flow (App action block, AI tool) or a campaign fallback asks the client app to run one of its registered actions. Answer it with `POST /v1/calls/{callId}/action-result` ([Part 2E, K3](2e-actions.md#k3-answer-an-action)).

```json
{ "id": "9c1d…", "type": "action.requested", "createdAt": "2026-11-20T09:12:30Z",
  "data": { "actionId": "c3a9e1f0-…", "name": "pay", "callId": "4b1e…", "accountRef": "duka-juma", "block": "pay_1",
            "params": { "amount": "45000", "phone": "+255712345678", "wallet": "M-Pesa" },
            "wait": true, "timeoutAt": "2026-11-20T09:14:30Z",
            "meta": { "client": { "orderId": "ORD-88" } } } }

```

<table id="bkmrk-field-description-pa"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`params`</td><td>Filled in: templates replaced by the call's values (`{caller}` → the number)</td></tr><tr><td>`wait` / `timeoutAt`</td><td>`true`: the caller is waiting; answer before `timeoutAt` or the flow takes its `timeout` exit</td></tr><tr><td>`meta.client`</td><td>The call's `meta` (from the call-out person, OTP request…)</td></tr></tbody></table>

A **campaign fallback** (after a person's last failed try) has no call to wait on: `data` is `{actionId, name, params, wait: false}`, and `params` also carries `to` (the person), `calloutId`, `accountRef` and `reason` (their last result, e.g. `NO_ANSWER`). Nothing waits for an answer.

### `request.failed`

A Send request block in "send and continue" mode delivers in the background with retries (30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h…). After 24 h of failures it gives up and tells the client app:

```json
{ "id": "…", "type": "request.failed", "createdAt": "2026-11-14T09:12:30Z",
  "data": { "callId": "4b1e…", "block": "notify_crm", "attempts": 9, "status": 503, "error": "Service Unavailable" } }

```

### `callout.progress` · `callout.completed` · `callout.paused`

Same shape: the call-out's counts. `progress` every 30 s while it runs; `completed` once at the end (also after `stop`); `paused` whenever it is paused (by you or by the engine), with `reason`.

```json
{ "id": "2d0dd47c-4f31-4b9f-a523-b3cd7e486f33", "type": "callout.completed", "createdAt": "2026-11-13T14:41:34.770Z",
  "data": { "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5", "externalRef": "schedule:account-callback:+255745051250",
            "status": "COMPLETED", "total": 1, "byStatus": { "DONE": 1 }, "byResult": { "ANSWERED": 1 }, "byOutcome": {},
            "meta": { "client": { "said": "After five minutes, baada ya dakika tano.", "scheduledBy": "ca10b2f7-a5a3-41bb-be8b-2e486b2b5dbf" } } } }

```

<table id="bkmrk-reason-%28paused%29-why-"><thead><tr><th>`reason` (paused)</th><th>Why</th></tr></thead><tbody><tr><td>`ACCOUNT_SUSPENDED` / `CLIENT_SUSPENDED`</td><td>The account or the client app was suspended</td></tr><tr><td>`VERSION_REVOKED`</td><td>Its flow version was stopped</td></tr><tr><td>`FEATURE_OFF`</td><td>The owner took the client app's `CALLOUTS` feature away</td></tr><tr><td>`CAN_CALL:<reason>`</td><td>The can-call check said no ([3.2](#32-the-can-call-check))</td></tr><tr><td>`PAUSED_BY_CLIENT` / your own text</td><td>You paused it (`POST …/pause`); the webhook is sent for these too</td></tr></tbody></table>

The engine never resumes a call-out it paused.

### `callout.recipient_results`

People who are finished, in batches (about every 30 s), so the client app can update its contacts.

```json
{ "id": "50c42336-a708-4289-a307-6a4dd6ceefae", "type": "callout.recipient_results", "createdAt": "2026-11-13T14:42:44.692Z",
  "data": { "calloutId": "510fca7b-b226-46ad-b80a-0082ab4ab0d5",
            "results": [ { "e164": "+255745051250", "result": "ANSWERED", "attempts": 1, "callId": "fff4aa3f-8ed5-44e6-947c-c82bb3628c41",
                           "answeredSeconds": 28, "keys": "1234", "outcomes": null } ] } }

```

### `agent.status`

Every status or presence change, for a wallboard.

```json
{ "id": "19908d3b-3d76-4473-a18b-9b100ad5ea80", "type": "agent.status", "createdAt": "2026-11-13T16:59:31.077Z",
  "data": { "accountRef": "softphone", "agentRef": "asha", "status": "AVAILABLE", "online": true,
            "departments": ["c09c1657-d18a-4a32-94c7-4ffa24e038c3"] } }

```

### `media.ready` · `media.failed`

```json
{ "id": "facd42e5-8867-4b10-ae19-cb7ecfe02578", "type": "media.ready", "createdAt": "2026-11-13T09:46:09.038Z",
  "data": { "mediaId": "eebb855e-2a03-4af7-a8c8-301f85877308", "accountRef": "softphone", "durationMs": 24192 } }

```

`media.failed`: `{"mediaId", "accountRef", "error"}`.

### `test.ping`

Not in the usual envelope:

```json
{ "type": "test.ping", "deliveryId": "d451ae97-ea90-4039-9098-0363acbdeda7", "clientAppId": "bd74035b-bdb6-450d-885b-222e83719906",
  "at": "2026-11-13T15:19:12.682569166Z" }

```

Signed like every webhook; answer 2xx.

### `agent.device_paired` · `agent.device_revoked`

An agent paired Agent Phone ([Part 2F](2f-agent-phone.md)), or a device stopped being allowed. `reason`: `REMOVED` (your dashboard), `SIGNED_OUT`, `TOKEN_REUSED` (a copied refresh token was used: treat as a security event), `UNUSED` (30 days), `AGENT_REMOVED`.

```json
{ "type": "agent.device_revoked", "data": { "accountRef": "softphone", "agentRef": "asha",
  "deviceId": "1400d27e-8e21-4f9c-be7d-8e7c77c5f660", "name": "Agent Phone on reception-pc", "platform": "linux", "reason": "REMOVED" } }

```

### `agent.shift_started` · `agent.shift_ended`

Shared computers ([Part 2F, K4](2f-agent-phone.md#k4-shared-computers-stations)). `reason` on `shift_ended`: `ENDED`(the agent), `DASHBOARD`, `SWITCHED` (another agent started a shift), `IDLE`, `END_TIME`, `STATION_REMOVED`, `AGENT_REMOVED`.

```json
{ "type": "agent.shift_ended", "data": { "accountRef": "softphone", "agentRef": "asha",
  "stationId": "6f1e…", "station": "Reception PC", "reason": "IDLE" } }

```

---

# 3.2 The can-call check

Optional. When the client app has a **can-call URL**, the engine asks it before calls, so the client app can apply its own rules (the business has credit, its plan allows calls now, a number is blocked):

```http
GET https://app.example.co.tz/hooks/can-call?accountRef=pmk-chemicals&type=INCOMING&from=%2B255745051250&to=%2B255677000010
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: <hex HMAC-SHA256(secret, "<timestamp>.")>      (empty body)

```

<table id="bkmrk-query-description-ac"><thead><tr><th>Query</th><th>Description</th></tr></thead><tbody><tr><td>`accountRef`</td><td>The account</td></tr><tr><td>`type`</td><td>`INCOMING` · `BROADCAST` · `CAMPAIGN` · `AGENT_CALL`</td></tr><tr><td>`from` / `to`</td><td>E.164 (`from` is empty for call-outs)</td></tr></tbody></table>

**Answer** (bare, or inside the usual envelope's `data`):

```json
{ "allowed": true, "maxMinutes": 10, "reason": null }

```

<table id="bkmrk-field-description-al"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`allowed`</td><td>`false` refuses the call (missing = `true`)</td></tr><tr><td>`maxMinutes`</td><td>Optional: end this call after this many minutes (e.g. the business's remaining credit)</td></tr><tr><td>`reason`</td><td>Your short code, kept with the refusal</td></tr></tbody></table>

**Time limit**: connect 0.7 s, answer 1.5 s. When the URL does not answer in time or fails:

<table id="bkmrk-call-type-when-the-c"><thead><tr><th>Call type</th><th>When the check fails</th><th>When `allowed: false`</th></tr></thead><tbody><tr><td>`INCOMING`</td><td>**Allowed** (a caller is never refused because the client app is down)</td><td>Refused: caller hears the service is unavailable; `result: REJECTED`</td></tr><tr><td>`BROADCAST` / `CAMPAIGN`</td><td>**Refused**</td><td>The call-out is paused (`CAN_CALL:<reason>`, `callout.paused`)</td></tr><tr><td>`AGENT_CALL`</td><td>**Refused**</td><td>`403 CAN_CALL_REFUSED` to `POST /v1/calls/agent`</td></tr></tbody></table>

For call-outs the check runs once per batch of people, not per person.

---

# 3.3 Agent desk signals

Sent by the engine into the agent's **lobby room** (LiveKit data messages, JSON) while their browser is connected ([Part 2D, I12](2d-agents.md#i12-lobby-token)).

<table id="bkmrk-type-when-fields-wha"><thead><tr><th>`type`</th><th>When</th><th>Fields</th><th>What the desk does</th></tr></thead><tbody><tr><td>`ring`</td><td>A caller is offered to this agent</td><td>`callId`, `from`, `department`, `departmentId`; `name`, `label`, `note` when the [contacts URL](#34-the-contacts-url) knows them</td><td>Show "Incoming call", play the ring, offer Accept / Decline</td></tr><tr><td>`ring_cancel`</td><td>The offer ended</td><td>`callId`, `reason`</td><td>Stop ringing, hide the card</td></tr><tr><td>`connecting`</td><td>The agent's **mobile** answered the ring (ring mode with mobiles)</td><td>`callId`, `via: "mobile"`</td><td>Show "On a call on your mobile"</td></tr><tr><td>`outgoing`</td><td>An agent call for this agent started (dashboard, dial pad, call back)</td><td>`callId`, `to`; `name`, `label`, `note`</td><td>Show "Calling…", [join](2d-agents.md#j7-join-an-outgoing-call)</td></tr><tr><td>`call_answered`</td><td>The other phone picked up an outgoing call</td><td>`callId`</td><td>Stop the ringback, start the timer</td></tr><tr><td>`call_recording`</td><td>The call is being recorded</td><td>`callId`</td><td>Show a REC badge</td></tr><tr><td>`call_ended`</td><td>The call is over, whoever hung up</td><td>`callId`, `result`</td><td>Close the call screen</td></tr><tr><td>`status`</td><td>This agent's status or presence changed (dashboard, calls, devices)</td><td>`status`, `online`, `since`</td><td>Update the status dot</td></tr><tr><td>`team`</td><td>A teammate's status changed (only when `showTeam`)</td><td>`ref`, `name`, `status`, `online`, `since`</td><td>Update the team list</td></tr><tr><td>`queue`</td><td>Callers joined or left one of the agent's departments</td><td>`departmentId`, `waiting`, `oldestSince`, `callers` (≤ 10, in order)</td><td>Update the waiting list</td></tr><tr><td>`config`</td><td>Departments, ring settings, name or the account's switches changed</td><td>`version`, `changed`</td><td>Fetch the [desk](2d-agents.md#i14-the-agents-desk) again</td></tr><tr><td>`device_paired`</td><td>Another device was paired for this agent</td><td>`deviceId`, `name`, `platform`</td><td>Notify "New device connected"</td></tr><tr><td>`device_revoked`</td><td>A device was removed</td><td>`deviceId`, `reason`</td><td>That device: back to pairing</td></tr><tr><td>`shift`</td><td>A shared computer's shift started (in the station's room `station-{id}`) or ended (there and in the agent's lobby)</td><td>`deviceId`, `state: "started"` + `agentRef`, `name` · `state: "ended"` + `reason`</td><td>That station: switch to the agent's desk, or back to waiting</td></tr></tbody></table>

<table id="bkmrk-ring_cancel.reason-m"><thead><tr><th>`ring_cancel.reason`</th><th>Means</th></tr></thead><tbody><tr><td>`answered_elsewhere`</td><td>Another agent accepted it</td></tr><tr><td>`caller_left`</td><td>The caller hung up or left the waiting room</td></tr><tr><td>`timeout`</td><td>Nobody pressed Accept within the department's ring time</td></tr><tr><td>`declined`</td><td>This agent declined</td></tr><tr><td>`not_joined`</td><td>The agent accepted but their browser never joined the call room (15 s); the caller went back to the queue</td></tr></tbody></table>

When the agent changes their own status while ringing (Away, Offline), the ring is cancelled without a signal: the desk already knows.

```json
{ "type": "ring", "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "from": "+255745051250", "department": "Mapokezi" }
{ "type": "ring_cancel", "callId": "8a5e282d-93a1-443b-81f3-4eb7294a0639", "reason": "timeout" }

```

A ring is sent once the caller has heard everything before the waiting music, so the agent never meets a caller who does not yet know they are being connected.

---

# 3.4 The contacts URL

Ringmaster keeps **no phone book**: when it needs a name, it asks your app. The Ringmaster owner sets your `contactsUrl` once (`ringmaster-admin client-app edit <app> --contacts-url https://…`). Requests are `GET`, signed like the can-call check (`X-Ringmaster-Timestamp`, `X-Ringmaster-Signature` = HMAC-SHA256 of `"<timestamp>."` with your webhook secret).

**Who is this number?** Asked when a call comes in (while the can-call check runs) and for numbers agents dial. At most 1 s; slow or failing answers mean "no name" and the call goes on.

```
GET {contactsUrl}?accountRef=softphone&number=%2B255745051250
→ 200 { "name": "John Mushi", "label": "VIP customer", "company": "Mushi Hardware",
        "note": "Prefers Swahili, calls about orders", "externalRef": "cust-1182" }
→ 404 (or {}) = unknown

```

**Search** (Agent Phone's dial pad, by name or number). At most 2 s, up to 10 answers.

```
GET {contactsUrl}?accountRef=softphone&q=john
→ 200 [ { "name": "John Mushi", "number": "+255745051250", "label": "VIP customer" }, … ]

```

<table id="bkmrk-where-the-answer-sho"><thead><tr><th>Where the answer shows</th><th></th></tr></thead><tbody><tr><td>Agents</td><td>Ring, call screen (name, label, note), queue list, recent calls, call log (`contactName`)</td></tr><tr><td>Flows</td><td>`{contact.name}`, `{contact.label}`, `{contact.company}`, `{contact.note}`, `{contact.ref}`; Condition `contactExists`</td></tr><tr><td>Timeline</td><td>A `CONTACT` event with the name and label</td></tr></tbody></table>

Answers are remembered for an hour (unknown numbers for 10 minutes). The call keeps the name it was shown with, as call history, deleted with the call.

# Part 4 — Reference

[← Contents](README.md) · Previous: [Part 3 — What the engine sends you](3-webhooks.md) · Next: [Part 5 — Call flow reference](5-flow-reference.md)

Every value the API returns or accepts, every error code, the limits and defaults.

---

## Calls

### `type`

<table id="bkmrk-value-call-incoming-"><thead><tr><th>Value</th><th>Call</th></tr></thead><tbody><tr><td>`INCOMING`</td><td>Someone called one of the account's numbers</td></tr><tr><td>`BROADCAST`</td><td>A quick call or broadcast call-out (also scheduled callbacks)</td></tr><tr><td>`CAMPAIGN`</td><td>A campaign call-out</td></tr><tr><td>`OTP`</td><td>A code read out (`POST /v1/otp`)</td></tr><tr><td>`AGENT_CALL`</td><td>An agent called someone from the browser (`POST /v1/calls/agent`)</td></tr><tr><td>`TEST`</td><td>A test call</td></tr></tbody></table>

### `direction`

`INBOUND` (to us) · `OUTBOUND` (from us).

### `status` (live state)

<table id="bkmrk-value-means-queued-w"><thead><tr><th>Value</th><th>Means</th></tr></thead><tbody><tr><td>`QUEUED`</td><td>Waiting for a free line (call-out pacing, concurrency)</td></tr><tr><td>`DIALING`</td><td>Dialling out</td></tr><tr><td>`RINGING`</td><td>The person's phone is ringing</td></tr><tr><td>`IN_SCRIPT`</td><td>Blocks are running</td></tr><tr><td>`WAITING`</td><td>In a waiting room (for an agent, a reply, an app action)</td></tr><tr><td>`CONNECTING`</td><td>An agent accepted; joining the call</td></tr><tr><td>`LIVE`</td><td>Talking with an agent</td></tr><tr><td>`ENDED`</td><td>Over</td></tr></tbody></table>

### `result` (how it went)

<table id="bkmrk-value-means-handled_"><thead><tr><th>Value</th><th>Means</th></tr></thead><tbody><tr><td>`HANDLED_BY_SCRIPT`</td><td>The flow dealt with it to the end</td></tr><tr><td>`HANDLED_BY_AGENT`</td><td>An agent talked to the caller</td></tr><tr><td>`HANDLED_BY_AI`</td><td>An AI Agent dealt with it</td></tr><tr><td>`VOICEMAIL`</td><td>The caller left a voicemail</td></tr><tr><td>`MISSED`</td><td>Waited for an agent; nobody answered before the give-up time</td></tr><tr><td>`ABANDONED`</td><td>Hung up while waiting for an agent</td></tr><tr><td>`HUNG_UP_EARLY`</td><td>Hung up in the first seconds</td></tr><tr><td>`REJECTED`</td><td>Refused before the flow ran (no flow, suspended, feature off, caller limit, can-call said no)</td></tr><tr><td>`FAILED`</td><td>Something broke (see `endReason`)</td></tr><tr><td>`ANSWERED`</td><td>Outgoing: the person answered</td></tr><tr><td>`NO_ANSWER`</td><td>Outgoing: rang, nobody answered</td></tr><tr><td>`BUSY`</td><td>Outgoing: line busy</td></tr><tr><td>`DECLINED`</td><td>Outgoing: the person rejected the call (SIP 603); never retried</td></tr><tr><td>`UNREACHABLE`</td><td>Outgoing: phone off or out of coverage</td></tr><tr><td>`INVALID_NUMBER`</td><td>Outgoing: the number does not exist</td></tr><tr><td>`NOT_DIALED`</td><td>Outgoing: never dialled (stopped, opted out, refused)</td></tr></tbody></table>

### `endReason`

<table id="bkmrk-value-means-script_e"><thead><tr><th>Value</th><th>Means</th></tr></thead><tbody><tr><td>`SCRIPT_END`</td><td>The flow ended it (Goodbye, an end of the flow)</td></tr><tr><td>`CALLER_HUNG_UP`</td><td>The caller hung up</td></tr><tr><td>`AGENT_HUNG_UP`</td><td>The agent hung up</td></tr><tr><td>`AI_ENDED`</td><td>The AI Agent ended it</td></tr><tr><td>`WAIT_GAVE_UP`</td><td>The waiting room's give-up time passed</td></tr><tr><td>`MAX_CALL_LENGTH`</td><td>`limits.maxCallMinutes` (or the can-call `maxMinutes`) reached</td></tr><tr><td>`LOOP_GUARD`</td><td>`limits.maxBlockVisits` blocks without any input from the caller</td></tr><tr><td>`NO_INPUT`</td><td>The caller never answered</td></tr><tr><td>`LIMIT`</td><td>Refused by a limit or rule before the flow ran</td></tr><tr><td>`TRANSFER_FAILED`</td><td>A transfer could not complete</td></tr><tr><td>`NETWORK`</td><td>The phone network dropped the call</td></tr><tr><td>`NOT_ANSWERED`</td><td>Outgoing: not answered (see `result`, `hangupCause`, `sipStatus`)</td></tr><tr><td>`SYSTEM_ERROR`</td><td>An engine error; the caller heard an apology</td></tr><tr><td>`LOST_FINAL_NOTIFICATION`</td><td>The end was never reported; closed by the engine's clean-up</td></tr></tbody></table>

### `endedBy`

`CALLER` · `AGENT` · `AI` · `SYSTEM` · `NETWORK` · `UNKNOWN`.

### `handledBy`

`SCRIPT` · `AGENT` · `AI` · `VOICEMAIL`.

---

## Call-outs

<table id="bkmrk-field-values-kind-br"><thead><tr><th>Field</th><th>Values</th></tr></thead><tbody><tr><td>`kind`</td><td>`BROADCAST` · `CAMPAIGN`</td></tr><tr><td>`status`</td><td>`DRAFT` · `SCHEDULED` · `RUNNING` · `PAUSED` · `COMPLETED` · `STOPPED` · `CANCELLED` (last three are final)</td></tr><tr><td>`pauseReason`</td><td>`PAUSED_BY_CLIENT` (or your text) · `ACCOUNT_SUSPENDED` · `CLIENT_SUSPENDED` · `VERSION_REVOKED` · `FEATURE_OFF` · `CAN_CALL:<reason>`</td></tr><tr><td>Recipient `status`</td><td>`PENDING` · `DIALING` · `DONE` · `SKIPPED`</td></tr><tr><td>Recipient `result`</td><td>A call `result` (above), or `OPTED_OUT`</td></tr><tr><td>Skipped `reason` (adding people)</td><td>`INVALID_NUMBER` · `NOT_TANZANIAN` · `DUPLICATE` · `OPTED_OUT`</td></tr></tbody></table>

---

## Agents and departments

<table id="bkmrk-field-values-agent-s"><thead><tr><th>Field</th><th>Values</th></tr></thead><tbody><tr><td>Agent `status`</td><td>`OFFLINE` · `AVAILABLE` · `RINGING` · `BUSY` · `WRAP_UP` · `AWAY`</td></tr><tr><td>Department `strategy`</td><td>`RING_ALL` · `ROUND_ROBIN`</td></tr><tr><td>Department `ifNobodyOnline`</td><td>`WAIT` · `EXIT_NO_ANSWER`</td></tr><tr><td>Number `ringMode`</td><td>`BROWSER` · `MOBILE` · `BROWSER_AND_MOBILE`</td></tr><tr><td>Desk signal `type`</td><td>`ring` · `ring_cancel` · `connecting`</td></tr><tr><td>`ring_cancel.reason`</td><td>`answered_elsewhere` · `caller_left` · `timeout` · `declined` · `not_joined`</td></tr></tbody></table>

---

## Numbers, accounts, flows, media

<table id="bkmrk-field-values-number-"><thead><tr><th>Field</th><th>Values</th></tr></thead><tbody><tr><td>Number `status`</td><td>`POOL` (owner's, not given out) · `ALLOCATED` (the client app's, free) · `ASSIGNED` (on an account)</td></tr><tr><td>Number `type`</td><td>`REGULAR` · `TOLL_FREE`</td></tr><tr><td>Account `status`</td><td>`ACTIVE` · `SUSPENDED`</td></tr><tr><td>Account `recording.mode`</td><td>`OFF` · `LIVE_PARTS`</td></tr><tr><td>Flow `direction`</td><td>`INCOMING` (start key `call`) · `OUTGOING` (start key `out`)</td></tr><tr><td>Media `kind`</td><td>`UPLOAD` · `RECORDING` · `SYSTEM`</td></tr><tr><td>Media `status`</td><td>`PENDING` · `PROCESSING` · `READY` · `FAILED`</td></tr><tr><td>Knowledge document `status`</td><td>`READY` · `REMOVED`</td></tr><tr><td>Transcript `status`</td><td>`READY` · `FAILED`</td></tr><tr><td>Transcript `sentiment`</td><td>`positive` · `neutral` · `negative`</td></tr><tr><td>Client app features</td><td>`INBOUND` · `CALLOUTS` · `OTP` · `AI` · `NUMBERS`</td></tr><tr><td>App action param `type`</td><td>`text` · `phone` · `money` · `number` · `choice`</td></tr><tr><td>Languages</td><td>`sw` · `en`</td></tr><tr><td>TTS voices (messages)</td><td>`marin` · `coral` · `shimmer` · `sage` · `cedar` · `ash` · `echo` · `ballad` · `verse`; `speed` 0.7–1.5</td></tr></tbody></table>

---

## Timeline events

What `GET /v1/calls/{callId}/timeline` can contain (`type`), by area. `data` holds the details.

<table id="bkmrk-area-events-start-an"><thead><tr><th>Area</th><th>Events</th></tr></thead><tbody><tr><td>**Start and end**</td><td>`CALL_RECEIVED` (incoming) · `DIALING` · `RINGING` · `ANSWERED` · `NOT_ANSWERED` (with `sipStatus`, `reason`) · `DIAL_FAILED` · `LIMIT` (refused: `kind`) · `SCRIPT_END` · `CALLER_LEFT` · `ENDED` (`result`, `endReason`, `endedBy`)</td></tr><tr><td>**Flow**</td><td>`BLOCK_ENTER` (`type`) · `SAY` (`text`, `voice`) · `PLAY` (audio) · `OUTCOME` (`tag`) · `CONDITION` (`test`, `result`) · `VARIABLE_MISSING` · `LANGUAGE` · `TRANSFER_OUT` / `TRANSFER_IN` (to another flow) · `EXIT_UNCONNECTED` (the call ended at an unwired exit) · `ERROR` (`code`, e.g. `ACTION_UNKNOWN`, `AI_FEATURE_OFF`, `DEPARTMENT_MISSING`)</td></tr><tr><td>**Caller input**</td><td>`KEY` · `INPUT` (Collect: `value`, `valid`, `by`) · `SPEECH` (`text`) · `MATCH` (menu speech → option) · `NO_INPUT` · `NOT_UNDERSTOOD` · `RETRY` (`attempt`) · `LATER` ("call me later" words) · `POLL` · `PICK` · `PICK_MORE` · `PICK_EMPTY` · `ANSWER_FROM_MENU` · `OPTED_OUT`</td></tr><tr><td>**Schedule call**</td><td>`SCHEDULE_HEARD` (`said`, `at`) · `SCHEDULE_CHANGED` · `SCHEDULE_REFUSED` (`reason`) · `CALL_SCHEDULED` (`at`, `script`, `said`)</td></tr><tr><td>**Waiting room**</td><td>`WAIT_START` (`reason`) · `WAIT_UPDATE` · `WAIT_OPTION` (`key`, `exit`) · `WAIT_END` (`seconds`, `gaveUp`)</td></tr><tr><td>**Agents**</td><td>`NOBODY_ONLINE` · `QUEUED` (`department`) · `RING` (`agents`) · `RING_TIMEOUT` · `RING_DECLINED` · `ACCEPTED` (`agent`) · `CONNECTING` · `AGENT_JOINED` · `LIVE_START` · `AGENT_LEFT` · `AGENT_NO_SHOW` · `HOLD` · `UNHOLD` · `TRANSFER` (`from`, `to`) · `AGENT_HANGUP`</td></tr><tr><td>**Recording**</td><td>`RECORDING_NOTICE` · `RECORDING_STARTED` · `VOICEMAIL` (`recorded`) · `RECORDING_READY` (`kind`, `seconds`) · `RECORDING_FAILED` · `TRANSCRIPT_READY` (`language`, `sentiment`)</td></tr><tr><td>**Integrations**</td><td>`REQUEST` · `REQUEST_SENT` · `REQUEST_QUEUED` · `REQUEST_CALLBACK` · `REPLY_NOT_JSON` · `ACTION_REQUESTED` (`name`, `actionId`) · `ACTION_RESULT` (`name`, `outcome`, `late`)</td></tr><tr><td>**AI Agent**</td><td>`AI_VOICE_DISCLOSURE` · `AI_AGENT_START` · `AI_STAGE` · `AI_HEARD` · `AI_SAID` · `AI_TOOL` · `AI_KNOWLEDGE` · `AI_FIELD` · `AI_HANDOVER` · `AI_FAREWELL` · `AI_EXIT` · `AI_ERROR` · `AI_ENDED` · `AI_USAGE`</td></tr><tr><td>**OTP**</td><td>`OTP_SENT` (the code masked: `••••`) · `OTP_READ`</td></tr><tr><td>**Voice worker**</td><td>`WORKER_JOINED` · `WORKER_LEFT` · `WORKER_ERROR` · `WORKER_USAGE`</td></tr></tbody></table>

New event types may be added; ignore types you don't know.

---

## Error codes

The `code` field of an error response.

### General

<table id="bkmrk-http-code-when-400-b"><thead><tr><th>HTTP</th><th>`code`</th><th>When</th></tr></thead><tbody><tr><td>400</td><td>`BAD_REQUEST`</td><td>Invalid JSON; a missing or wrong query parameter; a value not allowed (message says which)</td></tr><tr><td>401</td><td>`UNAUTHORIZED`</td><td>Missing or invalid API key</td></tr><tr><td>403</td><td>`CLIENT_SUSPENDED`</td><td>The client app is suspended by the owner</td></tr><tr><td>403</td><td>`FEATURE_OFF`</td><td>The client app doesn't have the feature: `CALLOUTS` (call-outs, quick calls), `AI` (flows using AI), `OTP`, `NUMBERS`</td></tr><tr><td>403</td><td>`CAN_CALL_REFUSED`</td><td>The client app's can-call check refused an agent call</td></tr><tr><td>404</td><td>`NOT_FOUND`</td><td>No such object, or it belongs to another client app</td></tr><tr><td>422</td><td>`VALIDATION_FAILED`</td><td>Field errors (`data: {"field": "message"}`), a script's error list, or a list of problems</td></tr><tr><td>429</td><td>`RATE_LIMITED`</td><td>Over the app's requests per second, or too many OTPs to one number</td></tr><tr><td>500</td><td>`INTERNAL_ERROR`</td><td>An engine bug</td></tr></tbody></table>

### By area (409 unless noted)

<table id="bkmrk-code-endpoint-means-"><thead><tr><th>`code`</th><th>Endpoint</th><th>Means</th></tr></thead><tbody><tr><td>`ACCOUNT_EXISTS`</td><td>create account</td><td>`externalRef` already used</td></tr><tr><td>`ACCOUNT_NOT_ACTIVE`</td><td>transfer number</td><td>The target account is suspended</td></tr><tr><td>`NUMBER_ALREADY_YOURS`</td><td>add number</td><td>Already in the client app's numbers</td></tr><tr><td>`NUMBER_TAKEN`</td><td>add number</td><td>Another client app has it</td></tr><tr><td>`NUMBER_ASSIGNED`</td><td>assign / remove number</td><td>On another account (release it first) / still on an account</td></tr><tr><td>`NUMBER_NOT_ASSIGNED`</td><td>transfer number</td><td>Not on an account: assign it instead</td></tr><tr><td>`SAME_ACCOUNT`</td><td>transfer number</td><td>Already on that account</td></tr><tr><td>`NUMBER_BUSY`</td><td>release / remove / transfer</td><td>A call is in progress on it</td></tr><tr><td>`NUMBER_IN_CALLOUT`</td><td>release / remove / transfer</td><td>An open call-out calls from it</td></tr><tr><td>`DIRECTION_FIXED`</td><td>create flow</td><td>A flow keeps its direction</td></tr><tr><td>`VERSION_EXISTS`</td><td>push version</td><td>Same number, different document</td></tr><tr><td>`VERSION_NOT_RUNNABLE`</td><td>put flow on number</td><td>Not approved, or stopped</td></tr><tr><td>`VERSION_REVOKED`</td><td>approve version, create call-out</td><td>The version was stopped</td></tr><tr><td>`SCRIPT_IN_USE`</td><td>delete flow</td><td>Numbers, call-outs or other flows use it (`data` lists them)</td></tr><tr><td>`MEDIA_IN_USE`</td><td>delete audio</td><td>Live flow versions use it (`data.usedBy`)</td></tr><tr><td>`STORAGE_QUOTA`</td><td>upload audio</td><td>The account's 500 MB of audio is used</td></tr><tr><td>`LIST_LOCKED`</td><td>add people</td><td>The call-out has started</td></tr><tr><td>`ALREADY_STARTED`</td><td>start call-out</td><td>Not a draft any more</td></tr><tr><td>`NO_RECIPIENTS`</td><td>start call-out</td><td>Nobody to call</td></tr><tr><td>`NOT_RUNNING`</td><td>pause call-out</td><td>Only running or scheduled ones</td></tr><tr><td>`NOT_PAUSED`</td><td>resume call-out</td><td>Only paused ones</td></tr><tr><td>`ALREADY_RUNNING`</td><td>cancel call-out</td><td>Running call-outs are stopped, not cancelled</td></tr><tr><td>`DEPARTMENT_IN_USE`</td><td>delete department</td><td>Live flow versions connect to it (`data.usedBy`)</td></tr><tr><td>`DEPARTMENT_BUSY`</td><td>delete department</td><td>Callers are waiting in it</td></tr><tr><td>`AGENT_BUSY`</td><td>set status, agent call</td><td>The agent is on a call</td></tr><tr><td>`NOT_RINGING`</td><td>accept, decline</td><td>The call is not ringing for this agent any more</td></tr><tr><td>`ALREADY_TAKEN`</td><td>accept</td><td>Another agent answered</td></tr><tr><td>`NOT_YOUR_CALL`</td><td>hold, unhold, transfer, hang up</td><td>The call is not with this agent</td></tr><tr><td>`ACTION_IN_USE`</td><td>register actions</td><td>Removing or narrowing an action a flow or call-out uses</td></tr><tr><td>`ACTION_ALREADY_RESOLVED`</td><td>action result</td><td>A different answer was already given</td></tr><tr><td>`ACTION_TIMED_OUT`</td><td>action result</td><td>Too late: the call took the `timeout` exit</td></tr></tbody></table>

### Script check codes

`errors[].code` / `warnings[].code` from checking or pushing a flow version. Errors stop the version from being stored; warnings don't. What each code means and how to fix it: [Part 5 §5.9](5-flow-reference.md#59-check-codes).

<table id="bkmrk-area-codes-structure"><thead><tr><th>Area</th><th>Codes</th></tr></thead><tbody><tr><td>**Structure**</td><td>`DIRECTION_MISSING` · `DIRECTION_MISMATCH` · `START_MISSING` · `START_UNKNOWN` · `TOO_MANY_BLOCKS` · `UNKNOWN_BLOCK_TYPE`</td></tr><tr><td>**Exits**</td><td>`EXIT_TARGET_MISSING` (points to no block) · ⚠ `EXIT_UNCONNECTED` (call ends there) · ⚠ `EXIT_UNKNOWN` (not an exit of that block) · `GOTO_TARGET` · `RETRIES`</td></tr><tr><td>**Messages**</td><td>`MESSAGE_MISSING` · `MESSAGE_SOURCE` · `MESSAGE_TEXT` · `MESSAGE_TOO_LONG` · `MEDIA` · `MEDIA_MISSING` · `UNKNOWN_VARIABLE` · `VARS_IN_INCOMING`</td></tr><tr><td>**Ask blocks**</td><td>`MENU_KEY` · `MENU_KEYS` · `MENU_KEY_LANG` · `MENU_OPTION` · ⚠ `OPTION_WITHOUT_WORDS` · `DIGITS` · `PATTERN` · `SAVE_AS` · `POLL_ANSWERS` · `PICK_SOURCE` · `PICK_PAGE` · `ASK_LENGTH` · `VOICEMAIL_LENGTH`</td></tr><tr><td>**Connect, waiting room**</td><td>`DEPARTMENT` · `WAIT_GIVE_UP` · `WAIT_OPTION` · ⚠ `WAIT_OPTIONS_UNANNOUNCED`</td></tr><tr><td>**Send request**</td><td>`REQUEST_URL` · `REQUEST_HTTPS` · `REQUEST_METHOD` · `REQUEST_BODY` · `REQUEST_AUTH` · `REQUEST_MODE` · `REQUEST_TIMEOUT` · `CALLBACK_URL`</td></tr><tr><td>**App action**</td><td>`ACTION` (unknown action or a parameter missing)</td></tr><tr><td>**Logic**</td><td>`CONDITION_TEST` · `TRANSFER_TARGET` · `RETURN_EXIT` · `RETURN_OUTSIDE_SHARED`</td></tr><tr><td>**Schedule call**</td><td>`SCHEDULE_TARGET` (no runnable Outgoing flow by that ref and version) · `SCHEDULE_TIME` (exactly one of `after`, `at`, `ask`) · `SCHEDULE_TRIES`</td></tr><tr><td>**AI Agent**</td><td>`AGENT_START` · `AGENT_MOVE` · `AGENT_TOOL` · `AGENT_FIELD` · `AGENT_KB` · `AI_LANGUAGE`</td></tr><tr><td>**Features**</td><td>`FEATURE_OFF` (only from `POST /v1/scripts/validate`: the block uses AI and the client app has no `AI` feature)</td></tr></tbody></table>

⚠ = warning.

---

## Limits and defaults

<table id="bkmrk-what-value-requests-"><thead><tr><th>What</th><th>Value</th></tr></thead><tbody><tr><td>Requests per second</td><td>per client app, set by the owner (`429` above it)</td></tr><tr><td>Blocks per flow</td><td>60 · menu keys 10 · retries 3 · text 500 characters</td></tr><tr><td>Account `limits` defaults</td><td>`maxCallMinutes` 15 · `maxWaitSeconds` 300 · `callerCallsPer10Min` 5 · `otpPerDestinationPer10Min` 3 · `voicemailMaxSeconds` 120 · `maxBlockVisits` 25</td></tr><tr><td>Account defaults</td><td>language `sw` · zone `Africa/Dar_es_Salaam` · hours Mon–Fri 08:00–17:00, Sat 09:00–13:00 · quiet hours 21:00–07:00 · recording `LIVE_PARTS` + notice · `retentionDays` 90 (1–3650)</td></tr><tr><td>Quick call</td><td>1–1000 people · `maxTries` 1–3 (default 1) · `retryGapSec` 300–86400 (default 300)</td></tr><tr><td>Call-out</td><td>`maxTries` 1–5 (default 1) · `retryGapSec` ≥ 300 (default 3600) · `pacePerMin` 1–600 (default 60) · ≤ 10,000 people per add</td></tr><tr><td>Scheduled callback</td><td>5 min to 30 days ahead · quiet hours move it forward · ≤ 3 pending per caller · a new one replaces the old for the same flow · needs `CALLOUTS`</td></tr><tr><td>Department</td><td>ring time 10–60 s (20) · wrap-up 0–300 s (0) · missed rings before Away 0–20 (5; 0 = never)</td></tr><tr><td>Agent</td><td>presence grace 30 s · join after accept 15 s · lobby / call tokens 10 min</td></tr><tr><td>App actions</td><td>≤ 50 · `timeoutSec` 10–1800 (default 120)</td></tr><tr><td>Audio upload</td><td>≤ 10 MB · ≤ 5 minutes · 500 MB per account</td></tr><tr><td>Knowledge document</td><td>`.txt` `.md` `.pdf` · ≤ 10 MB · text cut at 200,000 characters</td></tr><tr><td>OTP code</td><td>4–10 digits</td></tr><tr><td>Stats / exports</td><td>≤ 400 days per request · default last 7 days · CSV ≤ 100,000 rows</td></tr><tr><td>Webhook retries</td><td>1 min, 5 min, 30 min, 2 h, 6 h, 12 h … until 24 h</td></tr><tr><td>Can-call check</td><td>connect 0.7 s, answer 1.5 s</td></tr><tr><td>Pages</td><td>calls: size default 50 · recipients: default 100, max 500</td></tr><tr><td>Numbers that can be called</td><td>Tanzanian (`+255`) only</td></tr></tbody></table>

---

## Glossary

<table id="bkmrk-term-meaning-account"><thead><tr><th>Term</th><th>Meaning</th></tr></thead><tbody><tr><td>**Account**</td><td>One business of a client app</td></tr><tr><td>**Agent**</td><td>A person who answers calls (browser or mobile)</td></tr><tr><td>**App action**</td><td>Something only the client app can do (SMS, payment), asked for by a flow</td></tr><tr><td>**Block**</td><td>One step of a flow: say, menu, collect, connect, AI agent…</td></tr><tr><td>**Call-out**</td><td>Calls placed by the engine to a list of people</td></tr><tr><td>**Can-call check**</td><td>The engine asking the client app "may this call happen?"</td></tr><tr><td>**Client app**</td><td>A product built on the engine, with its own API key</td></tr><tr><td>**Department**</td><td>A group of agents a flow connects callers to</td></tr><tr><td>**Exit**</td><td>A named way out of a block (`next`, `noInput`, an option id); wired to the next block in `x`</td></tr><tr><td>**Flow / script**</td><td>What a call does, as blocks; stored in versions</td></tr><tr><td>**Lobby**</td><td>The agent's LiveKit room for ring signals</td></tr><tr><td>**Opt-out**</td><td>A number the account must never call</td></tr><tr><td>**Outcome tag**</td><td>A label a flow puts on a call (`paid`, `callback_scheduled`) for reports</td></tr><tr><td>**Quiet hours**</td><td>Times when no call-outs are made</td></tr><tr><td>**Recipient**</td><td>One person on a call-out</td></tr><tr><td>**Runnable**</td><td>A flow version that may run (approved)</td></tr><tr><td>**Version**</td><td>A frozen copy of a flow; numbers and call-outs point at one</td></tr><tr><td>**Waiting room**</td><td>Music, updates and key options while a caller waits (for an agent, a reply, a payment)</td></tr><tr><td>**Wrap-up**</td><td>Quiet time after a call before an agent is rung again</td></tr></tbody></table>

# Part 5 — Call flow reference

[← Contents](README.md) · Previous: [Part 4 — Reference](4-reference.md) · Next: [Part 6 — Building the screens](6-screens.md)

Everything inside a flow document: its parts, messages, variables, the 18 blocks with every setting and exit, the Waiting Room, the AI Agent, and every check code. It's written for the developer of a flow editor. What the editor does with the API (push, approve, put on a number) is in [Part 2B](2b-flows.md). How to build the editor screen is in [Part 6 §6.1](6-screens.md#61-the-flow-editor).

Two endpoints keep an editor honest:

- `GET /v1/scripts/schema` ([2A, A2](2a-setup.md#a2-script-schema)): block types, groups, exits and which need approval, generated from the engine's code. Build the block library from it.
- `POST /v1/scripts/validate` ([2A, A3](2a-setup.md#a3-check-a-script-dry-run)): the same checks as a push. Run it on every save and show the issues on the blocks ([§5.9](#59-check-codes)).

Conventions in this page:

- **Default** is what the engine uses when the field is absent. Leave a field out rather than sending the default.
- A setting marked **req** must be present, or the check reports an error.
- ⚠ next to a block = a risky block: a version with it should be approved before it runs ([Part 1 §1.6](1-how-it-works.md#16-a-call-flows-life)).

---

## 5.1 The flow document

```json
{
  "direction": "OUTGOING",
  "settings": { "language": "sw", "voice": { "sw": "marin", "en": "cedar" }, "speed": 1.0, "record": "off" },
  "vars": [ { "name": "name", "type": "text" }, { "name": "amount", "type": "money" } ],
  "start": { "out": "hello" },
  "blocks": {
    "hello": { "type": "say", "msg": { "src": "text", "text": "Habari {name}. Deni lako ni shilingi {amount}." },
               "x": { "next": "menu" } },
    "menu":  { "type": "menu", "msg": { "src": "text", "text": "Bonyeza moja kulipa sasa, mbili kuongea na mhasibu." },
               "options": [ { "k": "1", "id": "pay", "label": "Lipa", "words": ["lipa", "kulipa"] },
                            { "k": "2", "id": "staff", "label": "Mhasibu", "words": ["mhasibu", "mtu"] } ],
               "speech": { "match": "words" },
               "x": { "pay": "pay", "staff": "desk", "noInput": "bye", "notUnderstood": "bye" } },
    "pay":   { "type": "app_action", "action": "pay", "params": { "amount": "{amount}" }, "countAs": "pay_started",
               "x": { "paid": "thanks", "failed": "bye", "timeout": "bye" } },
    "desk":  { "type": "connect", "department": "c09c1657-…", "x": { "noAnswer": "bye" } },
    "thanks": { "type": "goodbye", "msg": { "src": "text", "text": "Asante, malipo yamepokelewa." } },
    "bye":   { "type": "goodbye" }
  }
}

```

(The Swahili reads: "Hello {name}. Your debt is {amount} shillings." · "Press one to pay now, two to talk to the accountant." · "Thank you, the payment was received.")

<table id="bkmrk-field-type-required-"><thead><tr><th>Field</th><th>Type</th><th>Required</th><th>Description</th></tr></thead><tbody><tr><td>`direction`</td><td>string</td><td>Yes</td><td>`INCOMING` (a number plays it), `OUTGOING` (call-outs, quick calls, scheduled calls play it) or `SHARED` (only other flows run it, through Transfer to script). Fixed for the flow: every version has the same one</td></tr><tr><td>`start`</td><td>object</td><td>Yes</td><td>The first block: `{"call": id}` for INCOMING, `{"out": id}` for OUTGOING, `{"in": id}` for SHARED</td></tr><tr><td>`blocks`</td><td>object</td><td>Yes</td><td>Block id → block. At most **60** blocks</td></tr><tr><td>`vars`</td><td>array</td><td>No</td><td>OUTGOING only: the values each person on a call-out brings (`{name, type}`; `type` is `text` (default) or `money`). Not allowed in INCOMING flows (`VARS_IN_INCOMING`)</td></tr><tr><td>`inputs`</td><td>string\[\]</td><td>No</td><td>SHARED only: what a Transfer to script block must pass in; used inside as `{in.<name>}`</td></tr><tr><td>`settings.language`</td><td>string</td><td>No</td><td>`sw` or `en`: the language the flow's fixed texts are made into audio in when the version is pushed. During a call, see [§5.2](#52-messages)</td></tr><tr><td>`settings.voice`</td><td>string or object</td><td>No</td><td>The voice for every text: `"marin"`, or one per language `{"sw": "marin", "en": "cedar"}`</td></tr><tr><td>`settings.speed`</td><td>number or object</td><td>No</td><td>0.7–1.5 (rounded to 0.05), or one per language. Default 1.0</td></tr><tr><td>`settings.record`</td><td>string</td><td>No</td><td>`"off"`: no live recording for calls on this flow, even if the account records. Absent: the account decides</td></tr></tbody></table>

### Block ids

- Any string that is unique in the flow (`menu`, `pay_1`, `b7`). The id is what exits point to, what the timeline names, and what per-block stats count by ([2B, D9](2b-flows.md#d9-block-stats-of-a-version)).
- Keep an id when the block is edited, so stats stay comparable across versions. Make a new id only for a new block.

### Every block's shape

```json
"pay": {
  "type": "app_action",            // required: one of the 18 types
  "…settings…": "…",               // the block's own settings (§5.5)
  "before": { "src": "text", "text": "Mazungumzo haya yanarekodiwa." },   // optional announcement (§5.4)
  "countAs": "pay_started",        // optional outcome tag (§5.4)
  "voice": "cedar", "speed": 1.1,  // optional: this block's voice and speed
  "wr": { … },                     // Waiting Room, on Connect / Send request / App action (§5.6)
  "x": { "paid": "thanks", "failed": "bye", "timeout": "bye" }   // exit name → block id
}

```

### Exits

- `x` maps each exit name to the id of the next block.
- **An exit that is missing or empty ends the call** with the system goodbye ("Asante kwa kupiga simu. Kwaheri." / "Thank you for calling. Goodbye."). The check warns `EXIT_UNCONNECTED` for each one, and the timeline records `EXIT_UNCONNECTED` when a call ends there. A warning is not an error: sometimes ending there is what the author wants.
- **Loops are allowed.** An exit may point back to an earlier block or to the block itself ("press 0 to repeat", back to the menu). A loop is stopped only when it goes 25 blocks in a row without the caller pressing a key, speaking or recording (`LOOP_GUARD`), so a caller going round a menu is never cut off.
- Exit names a block doesn't have get the warning `EXIT_UNKNOWN`; they're ignored.
- Not warned when unwired: `later` (optional), and exits the block can't take as set up (`notUnderstood` on a keys-only menu; Schedule's `notUnderstood` without `ask`, `declined` without confirmation).

---

## 5.2 Messages

Every place a flow speaks (`msg`, `before`, `ask`, `wr.start`, `wr.updates`, `thanks`) takes a **message**: one part, or a list of parts played one after another.

```json
{ "src": "text", "text": "Habari {name}", "lang": "sw", "voice": "marin", "speed": 1.0 }
{ "src": "upload", "media": "7f1c…" }
[ { "src": "upload", "media": "7f1c…" }, { "src": "text", "text": "Deni lako ni {amount}." } ]

```

("Hello {name}" · "Your debt is {amount}.")

<table id="bkmrk-field-description-sr"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`src`</td><td>`text` (the engine speaks it), or `record` / `upload` / `library` (an audio file, by `media` id). The three audio sources play the same way; the name only says where the file came from, for the editor</td></tr><tr><td>`text`</td><td>Up to **500** characters. May contain `{variables}` ([§5.3](#53-variables))</td></tr><tr><td>`media`</td><td>The id of an audio file of this account ([2B, E1](2b-flows.md#e1-upload-audio)). It must be `READY` when the version is pushed</td></tr><tr><td>`lang`</td><td>`sw` or `en`: the language of this text. Absent: the call's current language</td></tr><tr><td>`voice`, `speed`</td><td>This message's voice and speed. Absent: the block's, then the flow's `settings`, then the engine's default</td></tr></tbody></table>

**When audio is made.** Fixed texts (no `{`) are turned into audio when the version is pushed, so calls play them at once. Texts with variables are made during the call the first time each exact text is needed, then reused. Changing the voice or speed makes new audio.

**The call's language** (`{lang}`) starts as the account's `defaultLanguage`. It changes when the caller picks a menu option with `lang`, or when an AI Agent with AUTO language hears the caller's language. Engine messages ("Samahani…", the goodbye, Pick's "Bonyeza…") follow it. For a flow written in a language other than the account's default, put `lang` on each text, or start with a menu option that sets the language.

Voices: `marin` · `coral` · `shimmer` · `sage` · `cedar` · `ash` · `echo` · `ballad` · `verse`.

---

## 5.3 Variables

`{name}` in any text, URL, parameter or body is filled during the call. A variable with no value becomes empty, and the timeline records `VARIABLE_MISSING`. The check refuses names it can't know (`UNKNOWN_VARIABLE`).

### Always there

<table id="bkmrk-variable-value-%7Bcall"><thead><tr><th>Variable</th><th>Value</th></tr></thead><tbody><tr><td>`{caller}`</td><td>The other person's number: the caller (incoming) or the person called (outgoing). E.164</td></tr><tr><td>`{number}`</td><td>The business's number on this call</td></tr><tr><td>`{callId}`</td><td>The call's id</td></tr><tr><td>`{date}` · `{time}`</td><td>Now, in the account's time zone: `13/11/2026` · `14:05`</td></tr><tr><td>`{lang}`</td><td>The call's language: `sw` or `en`</td></tr><tr><td>`{key}`</td><td>The last menu key pressed</td></tr><tr><td>`{heard}`</td><td>The last thing the caller said (any Ask block)</td></tr><tr><td>`{answeredBy}`</td><td>Outgoing calls: `person` or `machine` (answering-machine detection). Detection runs only for flows that use this variable</td></tr></tbody></table>

### Made by blocks

<table id="bkmrk-variable-made-by-exa"><thead><tr><th>Variable</th><th>Made by</th><th>Example</th></tr></thead><tbody><tr><td>`{<var>}`</td><td>the call-out person's values (OUTGOING `vars`)</td><td>`{name}`, `{amount}`</td></tr><tr><td>`{input.<saveAs>}`</td><td>Collect number</td><td>`{input.account}`</td></tr><tr><td>`{answer.<saveAs>}`</td><td>Ask question</td><td>`{answer.problem}`</td></tr><tr><td>`{poll.<saveAs>}`</td><td>Poll (`saveAs`, else the block id)</td><td>`{poll.rating}` = `4`</td></tr><tr><td>`{choice.label}` · `{choice.index}` · `{choice.<field>}`</td><td>Pick from list</td><td>`{choice.id}`, `{choice.price}`</td></tr><tr><td>`{reply.code}` · `{reply.say}` · each `map` target</td><td>Send request</td><td>`{reply.code}` = `200`; `"map": {"data.balance": "balance"}` → `{balance}`</td></tr><tr><td>`{action.<block>.outcome}` · `{action.<block>.<key>}`</td><td>App action that waits (the `data` of its result)</td><td>`{action.pay.receipt}`</td></tr><tr><td>`{ai.<field>}`</td><td>AI Agent fields</td><td>`{ai.date}`</td></tr><tr><td>`{schedule.when}` · `{schedule.at}`</td><td>Schedule call</td><td>"kesho saa tatu asubuhi" · `2026-11-14T09:00`</td></tr><tr><td>`{in.<input>}`</td><td>inside a SHARED flow: what the Transfer block passed</td><td>`{in.account}`</td></tr><tr><td>`{queue.position}`</td><td>Connect, while waiting</td><td>`3`</td></tr></tbody></table>

How they're read aloud:

- `money` variables are read with thousands separators: 45000 → "45,000".
- Numbers in `{input.*}` and `{poll.*}` are read digit by digit, in words.
- Sensitive values (Collect with `sensitive`) are usable in the call but shown as `••••` in the timeline, webhooks and exports.

---

## 5.4 Settings any block can have

<table id="bkmrk-field-description-be"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`before`</td><td>**Announcement**: a message played once when the block starts, before its own prompt. Not repeated when the block asks again after a wrong key ("Mazungumzo haya yanarekodiwa", "This call is recorded")</td></tr><tr><td>`countAs`</td><td>**Outcome tag**: when a call reaches the block, the tag is added to the call (`booked`, `promised`, `wants_callback`). Reports and call-out stats count calls by tag. Some are added automatically: `opted_out` (Opt out), `callback_scheduled` (Schedule call), App action outcomes the client app mapped with `countAs`</td></tr><tr><td>`voice`, `speed`</td><td>The block's voice and speed, for its texts that don't set their own</td></tr></tbody></table>

The **Ask blocks** (Menu, Collect, Poll, Pick, Ask question, and Schedule call's questions) also share:

<table id="bkmrk-field-default-descri"><thead><tr><th>Field</th><th>Default</th><th>Description</th></tr></thead><tbody><tr><td>`timeoutSec`</td><td>8 (Collect 10)</td><td>Seconds of silence after the prompt before it counts as no input</td></tr><tr><td>`retries`</td><td>2</td><td>How many times the block asks again after no input, a wrong key or words it didn't understand. 0–3</td></tr><tr><td>`laterWords`</td><td>—</td><td>Extra words meaning "not now" for the `later` exit (below)</td></tr></tbody></table>

- **Keys always work.** A Menu, Poll or Pick also listens for speech when it has a `speech` setting; Collect when `speech: true`; Ask question always listens.
- The caller can press a key or start talking during the prompt: the prompt stops (barge-in).
- After all retries, the block leaves through its give-up exit (`noInput`, `notUnderstood`, `failed` or `silence`).
- **`later` exit** (Menu, Collect, Poll, Pick): when it is wired, words like "baadaye", "niko busy", "sina muda", "later", "call me back" take it instead of a retry. Wire it to a Schedule call block. When it isn't wired, those words are treated like any other answer.

---

## 5.5 The blocks

<table id="bkmrk-group-blocks-speak-s"><thead><tr><th>Group</th><th>Blocks</th></tr></thead><tbody><tr><td>Speak</td><td>[Say](#say)</td></tr><tr><td>Ask</td><td>[Menu](#menu) · [Collect number](#collect) ⚠ · [Poll](#poll) · [Pick from list](#pick) · [Ask question](#ask)</td></tr><tr><td>Connect</td><td>[Connect to department](#connect) · [Voicemail](#voicemail) · [Schedule call](#schedule) · [AI Agent](#ai_agent) ⚠</td></tr><tr><td>Integrate</td><td>[Send request](#request) ⚠ · [App action](#app_action) (⚠ when the action is risky)</td></tr><tr><td>Logic</td><td>[Condition](#condition) · [Go to](#goto) · [Transfer to script](#transfer) · [Return](#return)</td></tr><tr><td>End</td><td>[Opt out](#optout) · [Goodbye](#goodbye)</td></tr></tbody></table>

---

### `say`

Says a text or plays audio, then goes on.

<table id="bkmrk-setting-type-default"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td></td></tr></tbody></table>

**Exits**: `next`. **Timeline**: `SAY` / `PLAY`.

```json
"hi": { "type": "say", "msg": { "src": "text", "text": "Karibu PMK Chemicals." }, "x": { "next": "menu" } }

```

("Welcome to PMK Chemicals.")

---

### `menu`

Asks the caller to pick an option by key or, with speech on, by voice. Each option is an exit.

<table id="bkmrk-setting-type-default-1"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td>Say the keys and words: "Bonyeza moja au sema *mauzo*…" ("Press one or say *sales*…")</td></tr><tr><td>`options`</td><td>array</td><td>**req**</td><td>1–10 options (below)</td></tr><tr><td>`speech`</td><td>object</td><td>absent = keys only</td><td>`{"match": "words" | "smart", "minConfidence": 0.5, "language": "sw", "maxSec": 12}`</td></tr><tr><td>`timeoutSec` · `retries` · `laterWords`</td><td></td><td>8 · 2</td><td>[§5.4](#54-settings-any-block-can-have)</td></tr></tbody></table>

Each option:

<table id="bkmrk-field-description-k-"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`k`</td><td>The key: `0`–`9`, `*` or `#`, unique in the menu. May be empty only when speech is on (a voice-only option)</td></tr><tr><td>`id`</td><td>The exit name. Default: the key. Letters, digits, `_`; unique in the menu</td></tr><tr><td>`label`</td><td>The option's name, read back when the caller isn't understood ("Samahani, sijaelewa. Unaweza kusema: Mauzo au Msaada.", "Sorry, I did not understand. You can say: Sales or Help.")</td></tr><tr><td>`words`</td><td>Words that pick this option by voice (`["mauzo", "kununua", "bei"]`). With `match: words`, an option without words can only be picked by its key (warning `OPTION_WITHOUT_WORDS`)</td></tr><tr><td>`lang`</td><td>`sw` or `en`: picking this option also switches the call's language (a language menu)</td></tr></tbody></table>

`speech.match`:

- `words`: the caller's words are compared with each option's `words` (exact or close).
- `smart`: words first; if none match, the AI picks the option the caller meant from labels and words ("nataka kuongea na mtu wa malipo", "I want to talk to someone about payments"). Below `minConfidence` → not understood. Needs the client app's `AI` feature. Without it, the push is refused; if AI is taken away later, calls fall back to `words`.

**Exits**: each option's `id` · `noInput` (silence or wrong keys, after retries) · `notUnderstood` (speech that matched nothing, after retries; if not wired, `noInput` is used) · `later`. Flows from before 2026-11-12 used `keys` instead of `options` and the exit `noKey`. Both still run; write `options` and `noInput`.

**Timeline**: `KEY` (`digit`, `valid`) · `SPEECH` · `MATCH` (`option`, `by`: `key` / `words` / `smart`, `confidence`) · `NO_INPUT` · `RETRY` · `LANGUAGE` · `LATER`. **Webhook**: `call.block` `{type: "menu", option, by}`.

---

### `collect`

⚠ Risky. Collects a number (account, meter, PIN, amount) into `{input.<saveAs>}`.

<table id="bkmrk-setting-type-default-2"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td>"Weka namba ya akaunti, kisha alama ya reli." ("Enter your account number, then the hash key.")</td></tr><tr><td>`saveAs`</td><td>string</td><td>**req**</td><td>A variable name: `[a-zA-Z_][a-zA-Z0-9_]*`</td></tr><tr><td>`minDigits` · `maxDigits`</td><td>int</td><td>1 · 20</td><td>1–20, min ≤ max</td></tr><tr><td>`finishKey`</td><td>string</td><td>`#`</td><td>The key that ends the number</td></tr><tr><td>`pattern`</td><td>string</td><td>—</td><td>A regular expression the digits must match (`^07\d{8}$`)</td></tr><tr><td>`sensitive`</td><td>bool</td><td>false</td><td>PIN-like: kept encrypted, shown as `••••` everywhere; never listens for speech</td></tr><tr><td>`speech`</td><td>bool</td><td>false</td><td>Also accept a spoken number ("sifuri saba moja…", "zero seven one…"). Off by default: spoken account numbers are where speech recognition errs most</td></tr><tr><td>`timeoutSec` · `retries` · `laterWords`</td><td></td><td>10 · 2</td><td></td></tr></tbody></table>

A wrong length or pattern → "Namba uliyoingiza si sahihi." ("The number you entered is not valid.") and ask again.

**Exits**: `ok` · `failed` (after retries) · `later`. **Timeline**: `INPUT` (`value` or `••••`, `valid`, `by`: `key` / `voice`) · `NO_INPUT` · `NOT_UNDERSTOOD` · `RETRY`. **Webhook**: `call.block` `{type: "collect", saveAs, value}` (masked when sensitive).

---

### `poll`

A question answered with a key from 1 to 5 (rating, yes/no, survey), saved as `{poll.<saveAs>}`.

<table id="bkmrk-setting-type-default-3"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td>Say what each key means</td></tr><tr><td>`answers`</td><td>int</td><td>5</td><td>1–5: keys 1..answers are valid</td></tr><tr><td>`saveAs`</td><td>string</td><td>the block id</td><td></td></tr><tr><td>`labels`</td><td>array</td><td>—</td><td>`[{"k": "1", "label": "Mbaya", "words": ["mbaya"]}, …]`: names for reports, and words for speech</td></tr><tr><td>`speech`</td><td>bool or object</td><td>off</td><td>`true`, or `{"match": "words" | "smart", "minConfidence"}`. A spoken number ("tano", "five") always works</td></tr><tr><td>`timeoutSec` · `retries` · `laterWords`</td><td></td><td>8 · 2</td><td></td></tr></tbody></table>

**Exits**: `next` (answered) · `noInput` · `later`. **Timeline**: `POLL` (`answer`, `label`, `by`) · `KEY` · `NO_INPUT` · `NOT_UNDERSTOOD` · `RETRY`. **Webhook**: `call.block` `{type: "poll", question, answer, label}`.

---

### `pick`

Reads a list ("Bonyeza moja kwa Arusha. Bonyeza mbili kwa Dodoma…", "Press one for Arusha. Press two for Dodoma…") and lets the caller choose. Sets `{choice.*}`.

<table id="bkmrk-setting-type-default-4"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`source`</td><td>string</td><td>**req**</td><td>The id of a Send request block that waits for its reply (the reply's top-level `items` array), or `"inline"`</td></tr><tr><td>`items`</td><td>array</td><td>—</td><td>With `source: "inline"`: `[{"id": "aru", "label": "Arusha"}, …]`</td></tr><tr><td>`label`</td><td>string</td><td>`{label}`</td><td>How each item is read: fields of the item, `"{name} kwa shilingi {price}"` ("{name} for {price} shillings")</td></tr><tr><td>`msg`</td><td>message</td><td>—</td><td>Said once before the list</td></tr><tr><td>`perPage`</td><td>int</td><td>5</td><td>1–9 items per page; `0` reads the next page</td></tr><tr><td>`speech`</td><td>bool or object</td><td>off</td><td>The caller can say the item's name, its number, or "zaidi" / "more"</td></tr><tr><td>`timeoutSec` · `retries` · `laterWords`</td><td></td><td>8 · 2</td><td></td></tr></tbody></table>

**Exits**: `chosen` · `empty` (no items) · `noInput` · `later`. **Variables**: every plain field of the chosen item as `{choice.<field>}`, plus `{choice.label}` and `{choice.index}`. **Timeline**: `PICK` (`index`, `id`, `label`, `by`) · `PICK_MORE` · `PICK_EMPTY` · `KEY` · `NO_INPUT`. **Webhook**: `call.block` `{type: "pick", id, label}`.

---

### `ask`

Asks an open question; the caller's spoken answer becomes text in `{answer.<saveAs>}` and the call goes on. ("Tuambie tatizo lako.", "Tell us your problem.") Unlike Voicemail, nothing is recorded and the call doesn't end.

<table id="bkmrk-setting-type-default-5"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td></td></tr><tr><td>`saveAs`</td><td>string</td><td>**req**</td><td>Variable name</td></tr><tr><td>`maxSec`</td><td>int</td><td>20</td><td>5–60: the longest answer</td></tr><tr><td>`language`</td><td>string</td><td>the call's</td><td>`sw` / `en`: the language to listen for</td></tr><tr><td>`useMenuWords`</td><td>bool</td><td>false</td><td>If the Menu just before was answered by voice in 6 or more words, those words are the answer and the question is skipped</td></tr><tr><td>`retries`</td><td>int</td><td>2</td><td>Asked again after silence</td></tr></tbody></table>

**Exits**: `answered` · `silence`. **Timeline**: `SPEECH` · `NO_INPUT` · `RETRY` · `ANSWER_FROM_MENU`. **Webhook**: `call.block` `{type: "ask", saveAs, text}`.

---

### `connect`

Puts the caller in a department's queue and rings its agents in their browser ([Part 2D](2d-agents.md)). The caller waits in the Waiting Room until an agent accepts.

<table id="bkmrk-setting-type-default-6"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`department`</td><td>string</td><td>**req**</td><td>A department id of this account (not deleted)</td></tr><tr><td>`record`</td><td>bool</td><td>account</td><td>`false`: don't record the talk with the agent, even if the account records</td></tr><tr><td>`wr`</td><td>object</td><td>engine defaults</td><td>The Waiting Room ([§5.6](#56-the-waiting-room)): music, updates, queue position, option keys, give-up time</td></tr></tbody></table>

Agents are rung once the caller has heard everything before the music (the intro, `wr.start`). Whether members' mobiles ring too is the number's `ringMode` ([2A](2a-setup.md)), not a block setting.

**Exits**: `noAnswer` (nobody accepted before `wr.giveUpSec`; or nobody online and the department says "exit at once") · each `wr.options[].exit`. After an agent accepts, the flow doesn't go on: the call is with the agent until someone hangs up. **Timeline**: `WAIT_START` · `QUEUED` · `RING` · `RING_TIMEOUT` · `RING_DECLINED` · `ACCEPTED` · `AGENT_JOINED` · `LIVE_START` · `NOBODY_ONLINE` · `WAIT_UPDATE` · `WAIT_OPTION` · `WAIT_END`.

---

### `voicemail`

A prompt, a beep, then records the caller's message. Ends the call. The message is stored, transcribed if the account transcribes, and sent as `voicemail.received`.

<table id="bkmrk-setting-type-default-7"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>**req**</td><td>"Acha ujumbe baada ya mlio." ("Leave a message after the beep.")</td></tr><tr><td>`maxSec`</td><td>int</td><td>60</td><td>5–120, and never above the account's voicemail limit</td></tr><tr><td>`beep`</td><td>bool</td><td>true</td><td>One beep to start, two short beeps `warnBeforeEndSec` before the end, one long beep when time is up. Only the caller hears them; they're not in the recording</td></tr><tr><td>`warnBeforeEndSec`</td><td>int</td><td>10 (a third of a short `maxSec`)</td><td>0 = no warning</td></tr><tr><td>`thanks`</td><td>message</td><td>the system goodbye</td><td>Said after the message</td></tr></tbody></table>

The caller ends the message with `#` or by hanging up.

**Exits**: none (the call ends). **Timeline**: `VOICEMAIL` (`recorded`) · `RECORDING_READY` (`kind: VOICEMAIL`) · `TRANSCRIPT_READY`.

---

### `schedule`

Books a later call to this caller ("nipigie baadaye", "nikumbushe kesho asubuhi", "call me later", "remind me tomorrow morning"), played by an OUTGOING flow. Needs the client app's `CALLOUTS` feature. Full design: [SCHEDULE-CALL.md](../SCHEDULE-CALL.md).

<table id="bkmrk-setting-type-default-8"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`script` · `version`</td><td>string · int</td><td>**req**</td><td>An OUTGOING flow of this account and a runnable version of it</td></tr><tr><td>`after`</td><td>string</td><td>—</td><td>A fixed delay: `30m`, `1h`, `1d`</td></tr><tr><td>`at`</td><td>string</td><td>—</td><td>A fixed time: `17:00`, `today 17:00`, `tomorrow 09:00` (a time already past today means tomorrow)</td></tr><tr><td>`ask`</td><td>message</td><td>—</td><td>The caller says the time ("Tukupigie lini?", "When should we call you?")</td></tr><tr><td>`confirm`</td><td>bool</td><td>true with `ask`, else false</td><td>Say the time back and wait for yes / no (keys 1 / 2 too)</td></tr><tr><td>`vars`</td><td>object</td><td>—</td><td>`{name: template}`: values for the later call's flow (`{"order": "{input.order}"}`)</td></tr><tr><td>`note`</td><td>string</td><td>—</td><td>What it's about, shown on the scheduled call</td></tr><tr><td>`maxTries` · `retryGapSec`</td><td>int</td><td>2 · 300</td><td>1–3 tries; at least 300 s apart</td></tr><tr><td>`msg`</td><td>message</td><td>engine line</td><td>Said after booking; may use `{schedule.when}`</td></tr><tr><td>`timeoutSec` · `retries`</td><td></td><td>8 · 2</td><td>For the spoken time and the yes / no</td></tr></tbody></table>

Exactly one of `after`, `at`, `ask`.

Rules the engine applies, whatever the flow says:

- 5 minutes to 30 days ahead. A time too soon is moved up to the earliest allowed minute.
- Quiet hours move the time, and the read-back says so.
- A new booking for the same caller and flow replaces the old one; at most 3 waiting per caller.
- Times are understood by the engine's rules first ("kesho saa tatu asubuhi", "baada ya saa moja", "tomorrow at 9"). Other wordings ("tarehe 30") use the AI, only when the client app has `AI`.

**Exits**: `scheduled` · `notUnderstood` (the time wasn't understood, after retries) · `declined` (the caller said no to the time, after retries) · `refused` (the engine said no; `SCHEDULE_REFUSED.reason`: `TIME_OUT_OF_RANGE`, `OPTED_OUT`, `TOO_MANY_PENDING`, `SCRIPT_NOT_RUNNABLE`, `FEATURE_OFF`, `NO_TIME`). **Timeline**: `SCHEDULE_HEARD` (`said`, `at`) · `SCHEDULE_CHANGED` · `CALL_SCHEDULED` (`at`, `script`) · `SCHEDULE_REFUSED` (`reason`). **Webhook**: `call.scheduled` `{callId, calloutId, at, to, scriptRef, said}`.

---

### `ai_agent`

⚠ Risky. A spoken conversation with an AI, in stages, with its own knowledge, fields and tools. Needs the client app's `AI`feature. Its seven exits lead to any block. Full settings: [§5.7](#57-the-ai-agent).

**Exits**: `goal` (done) · `human` (wants a person: usually a Connect) · `vm` (leave a message) · `cb` (call me back) · `nohelp` (the caller ended it) · `time` (`maxMin` reached) · `err` (AI failed, didn't start in 20 s, or the client app lost `AI`).

---

### `request`

⚠ Risky. Calls the business's own HTTPS endpoint and maps the JSON reply into variables.

<table id="bkmrk-setting-type-default-9"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`url`</td><td>string</td><td>**req**</td><td>`https://` with a public host. `{variables}` in it are URL-encoded. Private, loopback and cloud-metadata addresses are refused</td></tr><tr><td>`method`</td><td>string</td><td>`POST`</td><td>`GET` · `POST` · `PUT` · `PATCH` · `DELETE`</td></tr><tr><td>`params`</td><td>object</td><td>—</td><td>Query parameters `{name: template}`</td></tr><tr><td>`headers`</td><td>object</td><td>—</td><td>`{name: template}`. `X-Voice-*`, `Host` and `Content-*` are set by the engine</td></tr><tr><td>`body`</td><td>object</td><td>`{"type": "json"}`</td><td>`type`: `json` · `form` · `raw` · `none`; `value`: the body, with variables</td></tr><tr><td>`auth`</td><td>object</td><td>`{"type": "signature"}`</td><td>`signature` (signed with the account's request secret) · `bearer` + `token` · `basic` + `username` + `password` · `apikey` + `header` + `value` · `none`</td></tr><tr><td>`map`</td><td>object</td><td>—</td><td>Reply JSON path → variable: `{"data.balance": "balance", "items[0].name": "first"}`</td></tr><tr><td>`timeoutSec`</td><td>int</td><td>10</td><td>1–15</td></tr><tr><td>`mode`</td><td>string</td><td>`reply`</td><td>`reply`: wait for the answer · `continue`: send and go on at once (queued, retried for up to 24 h) · `callback`: the business answers later at `{callback_url}`</td></tr><tr><td>`wr`</td><td>object</td><td>—</td><td>The Waiting Room for `reply` / `callback` when the answer takes more than about a second</td></tr></tbody></table>

What the engine sends and expects:

- Signed requests carry `X-Voice-Timestamp`, `X-Voice-Signature` (hex HMAC-SHA256 of `timestamp.body`), `X-Voice-Idempotency-Key` and `X-Voice-Call-Id`. JSON bodies get a `meta` object (`callId`, `callOutId`, `block`).
- 2xx = `success`. Any other status, a redirect (never followed), a reply over 64 KB, a blocked address or a network error = `failure`. No reply in time = `timeout`.
- A top-level `say` in the reply becomes `{reply.say}`. A top-level `items` array feeds Pick from list.
- `callback` mode: the body must contain `{callback_url}` (check `CALLBACK_URL`). The business POSTs the result there before `wr.giveUpSec`. `"result"` or `"status"` set to `failure`, `failed`, `error`, `declined` or `cancelled` → `failure`; anything else → `success`.
- Secrets (`auth.token`, `auth.password`, `auth.value`) are encrypted when the version is pushed and come back as `"(hidden)"` from [D6](2b-flows.md#d6-get-a-version-with-its-blocks). Push `"(hidden)"` back unchanged to keep the stored secret of the same block id in the latest version; send a new value to replace it. A `"(hidden)"` on a block that had no secret is refused (`REQUEST_AUTH`).

**Exits**: `continue` mode: `next`. Other modes: `success` · `failure` · `timeout` · each `wr.options[].exit`. **Timeline**: `REQUEST_SENT` · `REQUEST` (`status`, `latencyMs`, `outcome`, `saved`) · `REQUEST_QUEUED` · `REQUEST_CALLBACK` · `REPLY_NOT_JSON` · `WAIT_*`. **Webhook**: `request.failed` (a `continue` request that failed for 24 h); `call.block` with `late: true` when a callback arrives after the caller left.

---

### `app_action`

Asks the client app to do one of the actions it registered ([Part 2E](2e-actions.md)): send an SMS, take a payment, create a ticket. The engine has no built-in SMS or payment.

<table id="bkmrk-setting-type-default-10"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`action`</td><td>string</td><td>**req**</td><td>A name the client app registered</td></tr><tr><td>`params`</td><td>object</td><td>the action's defaults</td><td>`{name: template}`. Every required parameter must be given; values are type-checked during the call (`money`, `number`, `phone`, `choice`)</td></tr><tr><td>`msg`</td><td>message</td><td>—</td><td>Said before waiting ("Tafadhali thibitisha malipo kwenye simu yako.", "Please confirm the payment on your phone.")</td></tr><tr><td>`wr`</td><td>object</td><td>—</td><td>The Waiting Room while it waits</td></tr></tbody></table>

**Exits**:

- Action that doesn't wait: `next`. A bad parameter or unknown action → `failed`.
- Action that waits: each registered outcome (`paid`, `failed_pin`…) · `failed` · `timeout` (the action's `timeoutSec`, default 120) · each `wr.options[].exit`.

**Variables**: `{action.<block>.outcome}` and each key of the result's `data` as `{action.<block>.<key>}`. **Timeline**: `ACTION_REQUESTED` · `ACTION_RESULT` (`outcome`, `late`) · `ERROR` (`ACTION_UNKNOWN`, `ACTION_PARAMS`). The block is ⚠ when the action is registered as `risky`.

---

### `condition`

Branches on a test. Instant: the caller hears nothing.

<table id="bkmrk-test.kind-other-fiel"><thead><tr><th>`test.kind`</th><th>Other fields</th><th>Exits</th></tr></thead><tbody><tr><td>`hours`</td><td>—</td><td>`open` · `closed` (the account's opening hours, in its time zone)</td></tr><tr><td>`calledRecently`</td><td>`days` (default 7)</td><td>`yes` · `no`: an outgoing call from this account reached this caller in the last `days`</td></tr><tr><td>`variable`</td><td>`var`, `op`, `value`</td><td>`yes` · `no`</td></tr><tr><td>`contactExists`</td><td>—</td><td>`yes` when the client app's [contacts URL](3-webhooks.md#34-the-contacts-url) knew the caller, else `no`. Use `variable` on `contact.label` for a VIP path</td></tr></tbody></table>

`op` for `variable` (`value` may contain `{variables}`; text compares ignore case):

<table id="bkmrk-op-yes-when-equals-%28"><thead><tr><th>`op`</th><th>Yes when</th></tr></thead><tbody><tr><td>`equals` (default) · `notEquals`</td><td>the value is / isn't `value`</td></tr><tr><td>`gt` · `lt`</td><td>greater / less than `value` (as numbers; `45,000` works)</td></tr><tr><td>`in`</td><td>one of a comma list: `"value": "M-Pesa, Tigo Pesa"`</td></tr><tr><td>`matches`</td><td>matches a regular expression</td></tr><tr><td>`exists` · `empty`</td><td>has a value / has none (no `value` needed)</td></tr></tbody></table>

Answered by a machine (outgoing): `{"kind": "variable", "var": "answeredBy", "op": "equals", "value": "machine"}`.

**Timeline**: `CONDITION` (`test`, `result`).

---

### `goto`

Jumps to another block.

<table id="bkmrk-setting-type-default-11"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`target`</td><td>string</td><td>**req**</td><td>The block to jump to (`to` is accepted too)</td></tr><tr><td>`maxLoops`</td><td>int</td><td>0 = no limit</td><td>After this many jumps from this block in one call → `tooMany`</td></tr></tbody></table>

**Exits**: `tooMany`. Most editors draw `target` as a dashed line, not as an exit port. **Timeline**: `LIMIT` (`GOTO_LOOPS`).

---

### `transfer`

Runs a SHARED flow like a function: its Return blocks come back here, through the exit with the same name.

<table id="bkmrk-setting-type-default-12"><thead><tr><th>Setting</th><th>Type</th><th>Default</th><th>Rules</th></tr></thead><tbody><tr><td>`script` · `version`</td><td>string · int</td><td>**req**</td><td>A SHARED flow and a runnable version (pinned: changing the shared flow changes nothing here until the pin moves)</td></tr><tr><td>`inputs`</td><td>object</td><td>—</td><td>`{name: template}`: every input the shared flow declares</td></tr></tbody></table>

**Exits**: the names the shared flow's Return blocks use (`done`, `failed`…). Variables set inside stay visible after. At most 5 transfers deep. **Timeline**: `TRANSFER_IN` · `TRANSFER_OUT` (`exit`).

### `return`

SHARED flows only: goes back to the Transfer block.

<table id="bkmrk-setting-type-rules-e"><thead><tr><th>Setting</th><th>Type</th><th>Rules</th></tr></thead><tbody><tr><td>`exit`</td><td>string</td><td>**req**. `[a-zA-Z][a-zA-Z0-9_]{0,30}`</td></tr></tbody></table>

**Exits**: none.

---

### `optout`

Adds the caller to the account's do-not-call list ([2E](2e-actions.md)): call-outs never call them again.

<table id="bkmrk-setting-type-default-13"><thead><tr><th>Setting</th><th>Type</th><th>Default</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>"Umeondolewa kwenye orodha. Hutapigiwa tena." / "You have been removed from the list. We will not call again."</td></tr></tbody></table>

**Exits**: `next`. Adds the tag `opted_out`. **Timeline**: `OPTED_OUT`.

### `goodbye`

Ends the call.

<table id="bkmrk-setting-type-default-14"><thead><tr><th>Setting</th><th>Type</th><th>Default</th></tr></thead><tbody><tr><td>`msg`</td><td>message</td><td>"Asante kwa kupiga simu. Kwaheri." / "Thank you for calling. Goodbye."</td></tr></tbody></table>

**Exits**: none.

---

## 5.6 The Waiting Room

`wr` on Connect, Send request and App action: what the caller hears and can do while the block is busy.

```json
"wr": {
  "start": { "src": "text", "text": "Tafadhali subiri, tunakuunganisha. Bonyeza tisa kuacha ujumbe." },
  "music": "hold_default",
  "updates": [ { "src": "text", "text": "Bado tunakutafutia mhudumu." } ],
  "updateEverySec": 30,
  "queuePosition": true,
  "options": [ { "key": "9", "exit": "vm" } ],
  "optionsAfterSec": 20,
  "giveUpSec": 180
}

```

("Please wait, we're connecting you. Press nine to leave a message." · "We're still finding you an agent.")

<table id="bkmrk-field-default-descri-1"><thead><tr><th>Field</th><th>Default</th><th>Description</th></tr></thead><tbody><tr><td>`start`</td><td>—</td><td>Played once when waiting starts</td></tr><tr><td>`music`</td><td>`hold_default`</td><td>An audio id of the account, looped</td></tr><tr><td>`updates`</td><td>—</td><td>Messages played in turn, one every `updateEverySec`</td></tr><tr><td>`updateEverySec`</td><td>30</td><td></td></tr><tr><td>`queuePosition`</td><td>false</td><td>Connect only: "Wewe ni wa 3 kwenye foleni." ("You are number 3 in the queue.") with each update</td></tr><tr><td>`options`</td><td>—</td><td>`[{key, exit}]`: a key the caller can press to leave through that exit (voicemail, call me back, back to the menu). Key `0`–`9`, `*`, `#`. Each `exit` becomes an exit of the block</td></tr><tr><td>`optionsAfterSec`</td><td>0</td><td>Keys work only after this many seconds</td></tr><tr><td>`giveUpSec`</td><td>300</td><td>10–1800, and never above the account's longest wait. Then `noAnswer` (Connect), `timeout` (Send request, App action)</td></tr></tbody></table>

The Waiting Room says only what the author writes. **Announce the option keys** in `start` or `updates`; options with neither get the warning `WAIT_OPTIONS_UNANNOUNCED`.

---

## 5.7 The AI Agent

```json
"assistant": {
  "type": "ai_agent",
  "agent": {
    "goal": "Book an appointment",
    "persona": "Mhudumu mpole wa Kliniki ya Afya Bora",
    "guard": "Never promise prices that are not in the knowledge base",
    "voice": "marin", "maxMin": 5,
    "language": { "mode": "AUTO", "default": "sw" },
    "kb": { "items": [ { "q": "Saa za kazi", "a": "Jumatatu hadi Jumamosi, saa mbili asubuhi hadi saa kumi jioni." } ],
            "docs": ["4b1e…"], "site": "https://example.co.tz/huduma" },
    "fields": [ { "name": "patient_name", "desc": "Jina la mgonjwa" }, { "name": "date", "desc": "Siku ya miadi" } ],
    "tools": [ { "type": "request", "name": "book_slot", "req": "book" },
               { "type": "app action", "name": "send_confirmation", "action": "send_sms", "params": { "text": "Miadi yako ni {ai.date}" } } ],
    "canSchedule": { "script": "callback", "version": 1 },
    "start": "s1",
    "anytime": [ { "kind": "person", "to": "s5" }, { "kind": "upset", "to": "s5" } ],
    "stages": {
      "s1": { "type": "greet", "say": "Habari! Nikusaidie nini leo?", "leave": [ { "kind": "always", "to": "s2" } ] },
      "s2": { "type": "understand", "leave": [ { "kind": "question", "to": "s3" }, { "kind": "intent", "to": "s4" } ] },
      "s3": { "type": "answer", "leave": [ { "kind": "always", "to": "s2" } ] },
      "s4": { "type": "collect", "collect": ["patient_name", "date"], "leave": [ { "kind": "done", "to": "s6" } ] },
      "s6": { "type": "act", "actions": ["book_slot", "send_confirmation"],
              "leave": [ { "kind": "success", "to": "exit:goal" }, { "kind": "failed", "to": "s5" } ] },
      "s5": { "type": "handover", "ho": {
                "ok":     { "say": "Nakuunganisha na mhudumu sasa hivi.", "to": "exit:human" },
                "busy":   { "say": "Wahudumu wote wako kwenye simu, subiri kidogo.", "to": "exit:human" },
                "none":   { "say": "Tutakupigia ndani ya saa moja.", "to": "exit:cb" },
                "closed": { "say": "Tumefunga sasa, tafadhali acha ujumbe.", "to": "exit:vm" } } }
    }
  },
  "x": { "goal": "bye", "human": "desk", "vm": "vm", "cb": "later", "nohelp": "bye", "time": "bye", "err": "desk" }
}

```

(The Swahili reads: "A kind assistant of Afya Bora Clinic" · "Working hours" · "Monday to Saturday, 8 a.m. to 4 p.m." · "Patient's name" · "Day of the appointment" · "Your appointment is on {ai.date}" · "Hello! How can I help you today?" · "I'm connecting you to an agent now." · "All agents are on calls, please wait a little." · "We'll call you within an hour." · "We're closed now, please leave a message.")

<table id="bkmrk-field-default-descri-2"><thead><tr><th>Field</th><th>Default</th><th>Description</th></tr></thead><tbody><tr><td>`goal`</td><td>"help the caller"</td><td>What the conversation is for</td></tr><tr><td>`persona` · `guard`</td><td>—</td><td>Who the AI is; what it must never do</td></tr><tr><td>`voice` · `speed` · `model`</td><td>engine's</td><td>The realtime voice and model</td></tr><tr><td>`maxMin`</td><td>5</td><td>1–30 minutes, then the `time` exit</td></tr><tr><td>`language`</td><td>the call's</td><td>`{"mode": "FIXED", "languages": ["sw"]}` or `{"mode": "AUTO", "default": "sw"}` (follows the caller; sets `{lang}`)</td></tr><tr><td>`kb`</td><td>—</td><td>Knowledge: `items` (Q/A), `docs` (ids from [2B, F1](2b-flows.md#f1-upload-a-document)), `site` (an https page, read when the version is pushed). Used only in `answer` stages; the AI says it doesn't know rather than invent</td></tr><tr><td>`fields`</td><td>—</td><td>`[{name, desc}]`: details to collect; each becomes `{ai.<name>}`</td></tr><tr><td>`tools`</td><td>—</td><td>`[{type, name, …}]`. `name`: lower case, digits, `_`. Types: `request` + `req` (a Send request block of this flow, reused) · `app action` + `action` + `params` · `transfer` + `department`</td></tr><tr><td>`canSchedule`</td><td>—</td><td>`{script, version}`: lets the AI book a later call (same rules as Schedule call)</td></tr><tr><td>`start`</td><td>**req**</td><td>The first stage</td></tr><tr><td>`anytime`</td><td>—</td><td>Leave-when rows checked in every stage</td></tr><tr><td>`stages`</td><td>**req**</td><td>Stage id → stage</td></tr></tbody></table>

Stage:

<table id="bkmrk-field-description-ty"><thead><tr><th>Field</th><th>Description</th></tr></thead><tbody><tr><td>`type`</td><td>`greet` · `understand` · `answer` (from knowledge) · `collect` · `act` · `confirm` · `handover` · `custom`</td></tr><tr><td>`say`</td><td>Said on entering the stage</td></tr><tr><td>`instruction`</td><td>Extra instruction for this stage</td></tr><tr><td>`collect`</td><td>`collect` stages: field names (declared in `fields`)</td></tr><tr><td>`actions`</td><td>`act` stages: tool names</td></tr><tr><td>`leave`</td><td>`[{kind, to}]`. `to`: a stage id, or `exit:<goal|human|vm|cb|nohelp|time|err>`</td></tr><tr><td>`ho`</td><td>`handover` stages: `{ok, busy, none, closed}`, each `{say, to}`: chosen from the live state of the department behind the `human` exit (someone free / all busy / nobody online / closed). Defaults: ok, busy → human; none → cb; closed → vm</td></tr></tbody></table>

Leave-when kinds: `always` (then) · `question` (asks a question) · `intent` (wants to book or act) · `done` (all fields collected) · `success` · `failed` (an act stage's tools) · `person` (asks for a person) · `upset`.

After the conversation: the transcript (by speaker), a summary and the collected fields → `summary.ready`. **Timeline**: `AI_VOICE_DISCLOSURE` · `AI_AGENT_START` · `AI_STAGE` · `AI_HEARD` · `AI_SAID` · `AI_TOOL` · `AI_KNOWLEDGE` · `AI_FIELD` · `AI_HANDOVER` · `AI_EXIT` · `AI_ERROR` · `AI_ENDED` · `AI_USAGE`.

Rule of thumb: if the AI **needs a result to keep talking** (check a balance, book a slot), make it a tool. If the AI's part is **finished** (transfer, pay, goodbye), use an exit to a block.

---

## 5.8 What the engine adds by itself

Not settings: don't add blocks for these.

- **AI-voice notice**: a call that will play generated voice starts with one line, "Habari, unazungumza na huduma ya kidijitali ya {business}." ("Hello, you're speaking with {business}'s digital service."), worded per account. Skipped when the call plays only uploaded audio.
- **Recording notice**: once per call, before the first recorded part, when the account records.
- **"Sorry" and goodbye**: if something breaks during a call, the caller hears "Samahani, kuna tatizo kwa sasa…" ("Sorry, there's a problem right now…") and the call ends (`SYSTEM_ERROR`). The caller never hears silence.
- **Retry lines**: "Chaguo hilo si sahihi." ("That option is not valid.") for a wrong key; "Samahani, sijaelewa. Unaweza kusema: …" ("Sorry, I did not understand. You can say: …") with the option labels; "Namba uliyoingiza si sahihi." ("The number you entered is not valid.") in Collect; "Samahani, sikusikia vizuri. Tafadhali sema tena." ("Sorry, I did not hear that well. Please say it again.") in Ask question.

---

## 5.9 Check codes

`POST /v1/scripts/validate` and a push return `errors` and `warnings`, each `{block, code, message}`. `block` is the block id, or `null` for the whole flow. An error stops the push; a warning doesn't. Show each on its block; the `message` is written to be shown as it is.

<table id="bkmrk-code-level-block-%2F-f"><thead><tr><th>Code</th><th>Level</th><th>Block / field</th><th>How the author fixes it</th></tr></thead><tbody><tr><td>`DIRECTION_MISSING`</td><td>error</td><td>flow</td><td>Choose INCOMING, OUTGOING or SHARED</td></tr><tr><td>`DIRECTION_MISMATCH`</td><td>error</td><td>flow</td><td>A flow keeps its direction: make a new flow instead</td></tr><tr><td>`START_MISSING` · `START_UNKNOWN`</td><td>error</td><td>flow</td><td>Connect the start to a block</td></tr><tr><td>`TOO_MANY_BLOCKS`</td><td>error</td><td>flow</td><td>At most 60 blocks: move a part into a SHARED flow</td></tr><tr><td>`UNKNOWN_BLOCK_TYPE`</td><td>error</td><td>block</td><td>Engine doesn't know the type (old editor?): delete the block</td></tr><tr><td>`VARS_IN_INCOMING`</td><td>error</td><td>flow</td><td>Incoming flows have no call-out values: remove `vars`</td></tr><tr><td>`RETURN_OUTSIDE_SHARED`</td><td>error</td><td>flow</td><td>Return blocks only in SHARED flows</td></tr><tr><td>`EXIT_TARGET_MISSING`</td><td>error</td><td>block</td><td>An exit points to a deleted block: reconnect it</td></tr><tr><td>`EXIT_UNCONNECTED`</td><td>warning</td><td>block</td><td>The call ends there. Connect it, or leave it if that is meant</td></tr><tr><td>`EXIT_UNKNOWN`</td><td>warning</td><td>block</td><td>Not an exit of that block (old option id?): remove it</td></tr><tr><td>`RETRIES`</td><td>error</td><td>Ask blocks</td><td>0–3</td></tr><tr><td>`MESSAGE_MISSING` · `MESSAGE_SOURCE` · `MESSAGE_TEXT`</td><td>error</td><td>message</td><td>Write the text or choose audio</td></tr><tr><td>`MESSAGE_TOO_LONG`</td><td>error</td><td>message</td><td>At most 500 characters: split into two messages</td></tr><tr><td>`MEDIA_MISSING` · `MEDIA`</td><td>error</td><td>message, `wr.music`</td><td>Choose an audio file; it must be READY and belong to the account</td></tr><tr><td>`UNKNOWN_VARIABLE`</td><td>error</td><td>block</td><td>The `{name}` isn't produced by any block or declared: fix the name or add the variable</td></tr><tr><td>`MENU_KEYS`</td><td>error</td><td>menu</td><td>1–10 options</td></tr><tr><td>`MENU_KEY`</td><td>error</td><td>menu</td><td>Keys 0–9, \* or #, not repeated; an option without a key needs speech on</td></tr><tr><td>`MENU_OPTION`</td><td>error</td><td>menu</td><td>Two options with the same id</td></tr><tr><td>`MENU_KEY_LANG`</td><td>error</td><td>menu</td><td>An option's `lang` is `sw` or `en`</td></tr><tr><td>`OPTION_WITHOUT_WORDS`</td><td>warning</td><td>menu</td><td>With speech on, give the option words, or it can only be picked by key</td></tr><tr><td>`SAVE_AS`</td><td>error</td><td>collect, ask</td><td>A variable name: letters, digits, \_</td></tr><tr><td>`DIGITS` · `PATTERN`</td><td>error</td><td>collect</td><td>1–20 digits, min ≤ max; a valid regular expression</td></tr><tr><td>`POLL_ANSWERS`</td><td>error</td><td>poll</td><td>1–5</td></tr><tr><td>`PICK_SOURCE` · `PICK_PAGE`</td><td>error</td><td>pick</td><td>A Send request block that waits, or inline items; 1–9 per page</td></tr><tr><td>`ASK_LENGTH`</td><td>error</td><td>ask</td><td>5–60 seconds</td></tr><tr><td>`VOICEMAIL_LENGTH`</td><td>error</td><td>voicemail</td><td>5–120 seconds</td></tr><tr><td>`DEPARTMENT`</td><td>error</td><td>connect</td><td>Choose a department of this account</td></tr><tr><td>`WAIT_GIVE_UP` · `WAIT_OPTION`</td><td>error</td><td>`wr`</td><td>10–1800 s; each option a key and an exit</td></tr><tr><td>`WAIT_OPTIONS_UNANNOUNCED`</td><td>warning</td><td>`wr`</td><td>Tell callers about the keys in the start or update message</td></tr><tr><td>`REQUEST_URL` · `REQUEST_HTTPS`</td><td>error</td><td>request</td><td>An https URL with a public host</td></tr><tr><td>`REQUEST_METHOD` · `REQUEST_BODY` · `REQUEST_MODE` · `REQUEST_TIMEOUT`</td><td>error</td><td>request</td><td>A listed value; timeout 1–15 s</td></tr><tr><td>`REQUEST_AUTH`</td><td>error</td><td>request</td><td>Fill the auth fields for the chosen type</td></tr><tr><td>`CALLBACK_URL`</td><td>error</td><td>request</td><td>Callback mode: put `{callback_url}` in the body</td></tr><tr><td>`ACTION`</td><td>error</td><td>app\_action</td><td>An action the client app registered, with all its required parameters</td></tr><tr><td>`CONDITION_TEST`</td><td>error</td><td>condition</td><td>`hours`, `calledRecently`, `contactExists` or `variable`</td></tr><tr><td>`GOTO_TARGET`</td><td>error</td><td>goto</td><td>Choose the block to jump to</td></tr><tr><td>`TRANSFER_TARGET`</td><td>error</td><td>transfer</td><td>A runnable SHARED flow version; fill its inputs</td></tr><tr><td>`RETURN_EXIT`</td><td>error</td><td>return</td><td>An exit name</td></tr><tr><td>`SCHEDULE_TARGET`</td><td>error</td><td>schedule, AI `canSchedule`</td><td>A runnable OUTGOING flow version</td></tr><tr><td>`SCHEDULE_TIME`</td><td>error</td><td>schedule</td><td>Exactly one of after / at / ask, in the right form</td></tr><tr><td>`SCHEDULE_TRIES`</td><td>error</td><td>schedule</td><td>1–3</td></tr><tr><td>`AGENT_START` · `AGENT_MOVE` · `AGENT_TOOL` · `AGENT_FIELD` · `AGENT_KB` · `AI_LANGUAGE`</td><td>error</td><td>ai\_agent</td><td>First stage set; every move goes to a stage or an exit; tools and fields declared; knowledge docs of the account; site https</td></tr><tr><td>`FEATURE_OFF`</td><td>error</td><td>ai\_agent, menu/poll/pick with `smart`</td><td>The client app has no `AI` feature: use words matching, or remove the AI Agent</td></tr></tbody></table>

Besides the issues, the check returns:

- `texts`: the fixed texts that will be made into audio;
- `vars`: the declared call-out values (for the call-out upload screen);
- `riskyBlocks`: the blocks that need approval ([2B, D7](2b-flows.md#d7-approve-a-version-make-it-runnable)).

---

## Contact variables

Filled when a call starts, from the client app's [contacts URL](3-webhooks.md#34-the-contacts-url) (empty when it has none or the caller is unknown): `{contact.name}`, `{contact.label}`, `{contact.company}`, `{contact.note}`, `{contact.ref}`, and `contact.exists` (`true` / `false`) for the Condition block.

```json
"who":   { "type": "condition", "test": { "kind": "contactExists" }, "x": { "yes": "known", "no": "menu" } },
"known": { "type": "say", "msg": { "sw": "Habari {contact.name}, karibu tena." }, "x": { "next": "menu" } }

```

# Part 6 — Building the screens

[← Contents](README.md) · Previous: [Part 5 — Call flow reference](5-flow-reference.md) · Next: [Appendix](appendix.md)

How a client app turns the engine's API into screens for the businesses it serves: the flow editor, the calls page and call detail, the reports, the live screens, and the screens around Agent Phone ([§6.7](#67-screens-for-agent-phone)). Each section names the endpoints to use, what to do, and what not to do. The demo console (`console/`) is a working example of every screen here.

---

## 6.0 The shape of a client app

```
 browser (business staff)  ──►  client app backend  ──►  Ringmaster engine (/v1, API key)
                                       ▲
                                       └──  webhooks (signed) from the engine

```

**Do**

- Call the engine **only from the client app's backend**. The browser talks to the client app, never to the engine.
- Map your business to an engine account once (`externalRef` = your business id) and keep the `accountId`.
- Store what webhooks tell you: calls (`call.ended`), voicemails, summaries, call-out results. Your own reports and search then run on your own database, and the engine stays the source for detail (timeline, recording).
- On start-up, call `GET /v1/ping`: it returns the client app's `features`. Show only the screens the app may use (no Call-outs screen without `CALLOUTS`, no AI blocks without `AI`).
- Switch on the error `code`, show the `message` ([README](README.md#standard-response-format)). Messages are written for people.

**Don't**

- Don't put the API key, the webhook secret or a lobby token for someone else in a page, a mobile app or a log.
- Don't poll the engine to find out that something happened. Webhooks tell you ([§6.5](#65-which-webhook-updates-which-screen)).
- Don't fail on fields, enum values, timeline events or webhook types you don't know. The engine adds them within `/v1`.
- Don't show engine words to business staff when yours are clearer: "script" is "call flow" in this book; `MISSED` can be "Nobody answered".

---

## 6.1 The flow editor

### Load

<table id="bkmrk-what-endpoint-cache-"><thead><tr><th>What</th><th>Endpoint</th><th>Cache</th></tr></thead><tbody><tr><td>Block library (types, groups, exits, approval)</td><td>`GET /v1/scripts/schema`</td><td>Per `schemaVersion`; reload when it changes</td></tr><tr><td>The account's flows and their versions</td><td>`GET /v1/accounts/{id}/scripts` (D1)</td><td>—</td></tr><tr><td>A version's blocks</td><td>`GET …/scripts/{ref}/versions/{v}` (D6)</td><td>Versions never change: cache forever</td></tr><tr><td>Pickers: departments, audio, knowledge docs, app actions</td><td>I2, E2, F2, `GET /v1/actions`</td><td>Short</td></tr><tr><td>Other flows for Schedule call (OUTGOING) and Transfer (SHARED)</td><td>D1, then D6 of the chosen version</td><td>—</td></tr></tbody></table>

### Draw the blocks

Build the library from the schema's `blocks`, grouped by `group`, with ⚠ on `needsApproval`. Each block's ports (its exits) come from its settings:

<table id="bkmrk-block-ports-menu-eac"><thead><tr><th>Block</th><th>Ports</th></tr></thead><tbody><tr><td>`menu`</td><td>each `options[].id` (default: the key) · `noInput` · `notUnderstood` (only when `speech` is on) · `later`</td></tr><tr><td>`collect`</td><td>`ok` · `failed` · `later`</td></tr><tr><td>`poll`</td><td>`next` · `noInput` · `later`</td></tr><tr><td>`pick`</td><td>`chosen` · `empty` · `noInput` · `later`</td></tr><tr><td>`ask`</td><td>`answered` · `silence`</td></tr><tr><td>`connect`</td><td>`noAnswer` · each `wr.options[].exit`</td></tr><tr><td>`schedule`</td><td>`scheduled` · `notUnderstood` (only with `ask`) · `declined` (only with confirm) · `refused`</td></tr><tr><td>`request`</td><td>`mode: continue` → `next`; else `success` · `failure` · `timeout` · each `wr.options[].exit`</td></tr><tr><td>`app_action`</td><td>from the registered action (`GET /v1/actions`): no `wait` → `next`; `wait` → each `outcomes[]` · `failed` · `timeout` · each `wr.options[].exit`</td></tr><tr><td>`condition`</td><td>`test.kind: hours` → `open` · `closed`; others → `yes` · `no`</td></tr><tr><td>`goto`</td><td>`tooMany` (when `maxLoops` &gt; 0); draw `target` as a dashed line, not a port</td></tr><tr><td>`transfer`</td><td>the `exit` of each Return block in the chosen SHARED version</td></tr><tr><td>`ai_agent`</td><td>`goal` · `human` · `vm` · `cb` · `nohelp` · `time` · `err`</td></tr><tr><td>`say` · `optout`</td><td>`next`</td></tr><tr><td>`voicemail` · `goodbye` · `return`</td><td>none: an end block</td></tr></tbody></table>

Show the `later` port as optional ("if the caller says *later*"). Show ports with nothing connected in a different colour: the call ends there.

### Keep the layout yourself

The engine stores the flow, not the drawing. A top-level `layout` key is dropped on push. Store block positions in the client app, by account, flow `ref` and block id. Since ids stay the same across versions, the drawing carries over.

### Edit

- **Versions never change.** "Edit" means: load the latest version into a **draft** kept by the client app, change it, and push it as a new version. Keep the draft in your database so a closed tab loses nothing.
- Forms per block follow [Part 5](5-flow-reference.md): every setting there, with its default as the placeholder. Leave a field out instead of sending its default.
- Block ids: generate them (`b1`, `b2`… or a slug of the name) and **never change an existing block's id**. Stats are counted by id.
- Settings that point elsewhere use pickers, never free text: departments, audio, other flows, app actions.
- Variables: offer a picker with what the flow can use at that block ([Part 5 §5.3](5-flow-reference.md#53-variables)).
- Send request secrets show as `(hidden)`. Leave them as they are to keep them; let the user type a new one to replace.
- Without the `AI` feature, hide the AI Agent block and the `smart` matching choice.

### Check, save, publish

```
 every change (debounced ~1 s)  →  POST /v1/scripts/validate {definition}   → show issues on the blocks
 Save                           →  POST …/scripts/{ref}/versions {version: latest+1, runnable, definition}
 Approve (risky versions)       →  POST …/versions/{v}/runnable
 Go live                        →  PUT /v1/numbers/{numberId}/script {scriptVersionId}   (incoming)
                                    or pick the version in a call-out                    (outgoing)

```

- Show `errors` in red on their block, `warnings` in amber; `block: null` issues at the top ([Part 5 §5.9](5-flow-reference.md#59-check-codes)). Disable Save while there are errors.
- `version` = the highest existing version + 1. A `409 VERSION_EXISTS` means someone else saved first: reload, show their version, and let the user re-apply their change. A retry of the same push is safe (same document → `200`, not a new version).
- **Approval** is the client app's rule ([2B, D7](2b-flows.md#d7-approve-a-version-make-it-runnable)). A common rule: versions with `riskyBlocks` are pushed with `runnable: false` and a manager approves them; the rest go out `runnable: true`.
- Saving never changes what callers hear. Only "Go live" does. Say so on the button.
- **Roll back** = put an older runnable version back on the number. No push needed.
- **Stop** a version that must not run any more: `POST …/revoke` (D8). Numbers playing it refuse calls, so move them first.

### Delete

`DELETE …/scripts/{ref}` (D4). A `409 SCRIPT_IN_USE` lists what uses the flow (`numbers`, `callouts`, `scripts`): show the list with links. A flow that has run calls is archived, not erased: its call history stays readable.

### Per-block stats on the canvas

`GET …/versions/{v}/stats` (D9), for the version shown. Draw on each block:

- `visits` (how many calls came through);
- **drop-off** = `hangups / visits` (callers who hung up here: a long or confusing prompt);
- **confusion** = `retries / visits` and `noInput` (callers who didn't know what to press);
- Send request blocks: `reqFail`, `reqTimeout`, `reqMsP50`;
- Waiting rooms: `gaveUp`.

Stats appear a few minutes after calls end, and only for that version: compare versions side by side, not mixed.

### Try it

Push the version and put it on a test number, or call it with a quick call to your own phone (`POST /v1/calls`, needs `CALLOUTS`). Then open the call's timeline ([§6.2](#62-calls-page-and-call-detail)): it shows every block, sentence, key and match.

**Don't**

- Don't edit the JSON of a stored version and push it under the same number.
- Don't let users type department ids, audio ids or flow refs.
- Don't push on every keystroke: check on every change, push on Save.
- Don't put a version on a number before the push returned `201` (or `200` for the same document).

---

## 6.2 Calls page and call detail

### The calls list

`GET /v1/calls?accountId=…&page=…&size=50` (G1), newest first. Filters: `from` / `to`, `type`, `result`, `status`, `number`, `calloutId`.

<table id="bkmrk-column-from-show-as-"><thead><tr><th>Column</th><th>From</th><th>Show as</th></tr></thead><tbody><tr><td>When</td><td>`startedAt`</td><td>Local time of the business; "Today 14:05"</td></tr><tr><td>Who</td><td>`from` (incoming) / `to` (outgoing)</td><td>Your contact's name if you have one, else the number</td></tr><tr><td>Line</td><td>`to` (incoming) / `from` (outgoing)</td><td>The number's name in your app</td></tr><tr><td>Type</td><td>`type`</td><td>Incoming · Call-out · OTP · Agent call</td></tr><tr><td>Result</td><td>`result`</td><td>A coloured label ([table below](#result-labels))</td></tr><tr><td>Handled by</td><td>`handledBy` (+ agent from the timeline)</td><td>Flow · Agent Asha · AI · Voicemail</td></tr><tr><td>Length</td><td>`durations.totalSeconds`</td><td>2:31</td></tr><tr><td>🔊 · 📝</td><td>`recorded` · transcript</td><td>Icons; play inline</td></tr></tbody></table>

- **Live calls**: `status=LIVE`, or `status` not `ENDED`. Refresh this tab on `call.started` / `call.ended`, not with a timer.
- For search over months (by contact, by tag), use your own copy of `call.ended` events.

### Result labels

<table id="bkmrk-result-label-colour-"><thead><tr><th>`result`</th><th>Label</th><th>Colour</th></tr></thead><tbody><tr><td>`HANDLED_BY_SCRIPT`</td><td>Served by the flow</td><td>green</td></tr><tr><td>`HANDLED_BY_AGENT`</td><td>Talked to an agent</td><td>green</td></tr><tr><td>`HANDLED_BY_AI`</td><td>Served by AI</td><td>green</td></tr><tr><td>`VOICEMAIL`</td><td>Left a message</td><td>blue</td></tr><tr><td>`MISSED`</td><td>Nobody answered</td><td>red</td></tr><tr><td>`ABANDONED`</td><td>Hung up while waiting</td><td>red</td></tr><tr><td>`HUNG_UP_EARLY`</td><td>Hung up at once</td><td>grey</td></tr><tr><td>`REJECTED`</td><td>Refused (limits, suspended)</td><td>grey</td></tr><tr><td>`ANSWERED` (outgoing)</td><td>Answered</td><td>green</td></tr><tr><td>`NO_ANSWER` · `BUSY` · `DECLINED`</td><td>Not answered · Busy · Declined</td><td>amber</td></tr><tr><td>`UNREACHABLE` · `INVALID_NUMBER` · `FAILED` · `NOT_DIALED`</td><td>Couldn't reach · Wrong number · Failed · Not called</td><td>grey</td></tr></tbody></table>

### Call detail

One page per call, from four calls, loaded together:

<table id="bkmrk-part-endpoint-notes-"><thead><tr><th>Part</th><th>Endpoint</th><th>Notes</th></tr></thead><tbody><tr><td>Header: who, when, result, lengths, flow version, outcome tags</td><td>`GET /v1/calls/{id}` (G2)</td><td>`durations` as a bar: waiting · talking · on hold · AI</td></tr><tr><td>**Call log**</td><td>`GET /v1/calls/{id}/timeline` (G3)</td><td>Below</td></tr><tr><td>Recording</td><td>`GET /v1/calls/{id}/recording` (G4)</td><td>Proxy it through your backend; `404` = none or not ready</td></tr><tr><td>Transcript and summary</td><td>`GET /v1/calls/{id}/transcript` (G5)</td><td>`404` until `summary.ready`; show "Being transcribed…" if `recorded`</td></tr></tbody></table>

`providerCost` is what the call cost to run (carrier, AI). Show it to the client app's own staff, not to the business, unless that is how you bill.

### The call log (timeline → readable lines)

The timeline is the call's log: every step with its time. Show it as lines, `offsetMs` as `0:12`. Group lines under the block they happened in (`block`). Show the readable ones by default; put the rest behind "Show technical details".

<table id="bkmrk-event-line-default-c"><thead><tr><th>Event</th><th>Line</th><th>Default</th></tr></thead><tbody><tr><td>`CALL_RECEIVED` / `DIALING` / `ANSWERED`</td><td>"Call from +255745… to Mapokezi line" / "Calling…" / "Answered"</td><td>shown</td></tr><tr><td>`BLOCK_ENTER`</td><td>Block heading: the block's name and type</td><td>as heading</td></tr><tr><td>`SAY`</td><td>🔈 "Karibu PMK Chemicals." (`data.text`)</td><td>shown</td></tr><tr><td>`PLAY`</td><td>🔈 the audio's name</td><td>shown</td></tr><tr><td>`KEY`</td><td>⌨ "Pressed 2" (red if `valid: false`)</td><td>shown</td></tr><tr><td>`SPEECH` / `AI_HEARD`</td><td>🗣 "nataka kununua"</td><td>shown</td></tr><tr><td>`MATCH`</td><td>→ "Chose **Mauzo** (by voice, 92%)"</td><td>shown</td></tr><tr><td>`INPUT`</td><td>⌨ "Entered 1234 (valid)" or `••••`</td><td>shown</td></tr><tr><td>`NO_INPUT` / `NOT_UNDERSTOOD` / `RETRY`</td><td>"No answer — asked again (2)"</td><td>shown</td></tr><tr><td>`POLL` / `PICK`</td><td>"Answered 4 (Nzuri)" / "Chose Arusha"</td><td>shown</td></tr><tr><td>`CONDITION`</td><td>"Opening hours: open"</td><td>shown</td></tr><tr><td>`QUEUED` / `RING` / `ACCEPTED` / `RING_TIMEOUT`</td><td>"Waiting for Mapokezi" / "Ringing Asha" / "**Asha** answered" / "Asha didn't answer"</td><td>shown</td></tr><tr><td>`WAIT_OPTION`</td><td>"Pressed 9 while waiting → Voicemail"</td><td>shown</td></tr><tr><td>`VOICEMAIL`</td><td>"Left a message" with the player</td><td>shown</td></tr><tr><td>`CALL_SCHEDULED`</td><td>"Callback booked for tomorrow 09:00"</td><td>shown</td></tr><tr><td>`ACTION_REQUESTED` / `ACTION_RESULT`</td><td>"Asked the app: pay" / "pay → paid"</td><td>shown</td></tr><tr><td>`REQUEST`</td><td>"GET shop.example → 200 in 340 ms"</td><td>shown</td></tr><tr><td>`AI_SAID` / `AI_STAGE` / `AI_TOOL`</td><td>🤖 the sentence / stage / tool</td><td>shown</td></tr><tr><td>`EXIT_UNCONNECTED`</td><td>⚠ "The flow ends here (exit *noInput* not connected)"</td><td>shown: it's a flow bug the business can fix</td></tr><tr><td>`ERROR`</td><td>⚠ `data.code` explained</td><td>shown</td></tr><tr><td>`ENDED`</td><td>"Ended: caller hung up" (`endReason`, `endedBy`)</td><td>shown</td></tr><tr><td>`WORKER_*` · `AI_USAGE` · `WAIT_UPDATE` · `TRANSFER_IN/OUT` · `VARIABLE_MISSING` · others</td><td>as they are</td><td>hidden</td></tr></tbody></table>

Link each block heading to the flow editor at that block, on the version the call ran (`script.version` from G2).

**Don't**

- Don't send the recording's URL with the API key to the browser. Stream it through your backend, after checking that the user may hear it.
- Don't show a sensitive value; the engine already masks it as `••••`, and so should anything you add.
- Don't poll for the transcript: wait for `summary.ready`.

---

## 6.3 Reports

### Account dashboard

`GET /v1/accounts/{id}/stats?from=…&to=…&groupBy=day` (N1).

<table id="bkmrk-tile-%2F-chart-formula"><thead><tr><th>Tile / chart</th><th>Formula</th></tr></thead><tbody><tr><td>Calls</td><td>`incoming` + `outgoing`</td></tr><tr><td>Didn't get a person</td><td>`missed` + `abandoned`: callers who waited for an agent and gave up or weren't answered; as a share of `incoming`</td></tr><tr><td>Left a message</td><td>`voicemail`</td></tr><tr><td>Served without a person</td><td>(`byScript` + `byAi`) / (`byScript` + `byAgent` + `byAi` + `voicemail`)</td></tr><tr><td>Hung up at once</td><td>`hungUpEarly` / `incoming` (a long or unclear greeting)</td></tr><tr><td>Average call length</td><td>`seconds` / `answered` (`answered` counts incoming calls and answered outgoing ones)</td></tr><tr><td>Busiest hours</td><td>`groupBy=hour`: a heat map by hour</td></tr><tr><td>Per line</td><td>`groupBy=number`</td></tr><tr><td>By type</td><td>`groupBy=type`</td></tr></tbody></table>

Range: at most 400 days. Default view: the last 7 days.

### Flow performance

- **Funnel per block**: D9 on the canvas ([§6.1](#per-block-stats-on-the-canvas)).
- **Outcome tags** (`countAs`, App action outcomes): per call in `outcomes` (G2, `call.ended`), per call-out in `byOutcome` (H8). For account-wide numbers by tag, count them in your own copy of `call.ended`, or from the CSV export (`outcomes` column).

### Call-outs

<table id="bkmrk-screen-part-endpoint"><thead><tr><th>Screen part</th><th>Endpoint</th></tr></thead><tbody><tr><td>List and status</td><td>`GET /v1/callouts?accountId=…` (H5)</td></tr><tr><td>Header: status, counts, pause reason</td><td>`GET /v1/callouts/{id}` (H7)</td></tr><tr><td>Progress bar, by result, by try, by hour</td><td>`GET /v1/callouts/{id}/stats` (H8)</td></tr><tr><td>People and their result</td><td>`GET /v1/callouts/{id}/recipients?status=…` (H6)</td></tr><tr><td>Download</td><td>`GET /v1/callouts/{id}/export.csv` (H9)</td></tr></tbody></table>

While it runs, update the page from `callout.progress` (every 30 s) instead of polling. Show `callout.paused` reasons as actions: `VERSION_REVOKED` (choose another version), `FEATURE_OFF` (ask the owner), `ACCOUNT_SUSPENDED` / `CLIENT_SUSPENDED` (resumes when lifted), `CAN_CALL:<reason>` (your own can-call check said no). Quiet hours don't pause a call-out: it waits and goes on by itself.

### Agents

`GET /v1/accounts/{id}/agents/stats` (I13): per agent `offered`, `answered`, `missed`, `declined`, `talkSeconds`, and the declines by reason (`declinedWithCustomer`, `declinedBreak`, `declinedWrongDepartment`, `declinedOther`). Answer rate = `answered` / `offered`. Show the reasons next to `declined`, so a manager sees *why* (four "with a customer" in a busy shop is not the same as four "other"). Live status comes from `agent.status`; live declines from `call.ring_declined`.

### Exports

`GET /v1/calls/export.csv?accountId=…&from=…&to=…` (G6) and H9. Stream them through your backend as downloads; up to 100,000 rows, oldest first.

**Don't**

- Don't compute reports by walking `GET /v1/calls` page by page. Use the stats endpoints, the exports, or your own copy of `call.ended`.
- Don't mix time zones: stats days and hours are in the **account's** time zone, timestamps are UTC.

---

## 6.4 Live screens

### The agent desk

Everything is in [Part 2D](2d-agents.md) and the desk signals in [Part 3](3-webhooks.md). In short:

1. Agent signs in to the client app → backend `PUT …/agents/{ref}` (once) and `POST …/agents/{ref}/lobby-token`.
2. The page joins the LiveKit lobby room with that token and listens for `ring`, `ring_cancel`, `connecting`.
3. **Start shift** button: unlocks the browser's sound (a ring needs a user gesture) and sets status `AVAILABLE`.
4. On `ring`: show the caller and the flow's notes, ring, flash the title. Accept → backend `POST /v1/calls/{id}/accept`→ join the call room with the returned token. Decline → `POST …/decline`.
5. In the call: Hold, Transfer (to a department), Hang up, each through the backend.
6. Renew the lobby token before it expires (600 s).
7. Next to Agent Phone: stay quiet when a ring says `"audible": "phone"`, and offer **Take** on waiting callers ([§6.7.11](#6711-a-browser-desk-next-to-agent-phone)).

### Queue wall

`GET …/departments/{id}/queue` (I6) for the waiting callers. Refresh it when `agent.status`, `call.started` or `call.ended` arrive for that account, with a slow timer as backup (≥ 10 s).

**Don't**

- Don't let the browser call `accept` or `hangup` on the engine directly. The backend checks the agent is who they say.
- Don't ring with an `<audio>` element created before the user clicked anything: browsers block it.

---

## 6.5 Which webhook updates which screen

<table id="bkmrk-webhook-update-call."><thead><tr><th>Webhook</th><th>Update</th></tr></thead><tbody><tr><td>`call.started` / `call.answered`</td><td>Live calls list; queue wall</td></tr><tr><td>`call.block`</td><td>The caller's answers as they happen (a menu choice, a number, a poll answer); act on them in your app (open a ticket)</td></tr><tr><td>`call.ended`</td><td>Calls list, call detail, your own call store (reports, search)</td></tr><tr><td>`call.missed`</td><td>"Missed calls" inbox; notify the business</td></tr><tr><td>`call.scheduled`</td><td>Scheduled callbacks list</td></tr><tr><td>`voicemail.received` · `recording.ready`</td><td>Voicemail inbox; the call's player</td></tr><tr><td>`summary.ready`</td><td>Transcript and summary on the call; voicemail preview</td></tr><tr><td>`ai.unanswered`</td><td>"Questions the AI couldn't answer": suggestions for the knowledge base</td></tr><tr><td>`action.requested`</td><td>Not a screen: do the action and answer ([2E](2e-actions.md))</td></tr><tr><td>`request.failed`</td><td>An alert for the business's integration</td></tr><tr><td>`callout.progress` · `callout.paused` · `callout.completed` · `callout.recipient_results`</td><td>Call-out page; your own record of who was reached</td></tr><tr><td>`agent.status`</td><td>Agent list, desk, queue wall, the agent's status switch</td></tr><tr><td>`call.ring_declined`</td><td>Manager's live board (declines with reasons)</td></tr><tr><td>`agent.device_paired` · `agent.device_revoked`</td><td>Connect dialog, device lists, security notices ([§6.7](#6710-notices-from-the-engine))</td></tr><tr><td>`agent.shift_started` · `agent.shift_ended` · `station.paired` · `station.removed`</td><td>Shared computers page; "Start my shift" on the agent's home</td></tr><tr><td>`media.ready` · `media.failed`</td><td>Audio library (processing → ready)</td></tr></tbody></table>

Webhooks can arrive twice or out of order: store by `id`, and compare `createdAt` before overwriting newer data.

---

## 6.6 Don't, in one place

1. Never put the API key or webhook secret in a browser, app or log.
2. Never edit a stored version: push a new one.
3. Never change an existing block's id: stats and links are by id.
4. Never let users type ids (departments, audio, flows, actions): use pickers.
5. Don't poll for what a webhook tells you (calls, recordings, summaries, call-out progress, agent status).
6. Don't fail on unknown fields, values, events or webhook types.
7. Don't offer what the client app's features don't allow (`GET /v1/ping` → `features`).
8. Don't put a version live that is waiting for approval; don't approve in the same click as saving a risky version.
9. Don't show `••••` values unmasked, or engine costs to businesses unless you bill by them.
10. Don't build reports by paging through `GET /v1/calls`.
11. Don't send or store Agent Phone pairing codes, and don't ignore a `TOKEN_REUSED` revoke ([§6.7.12](#6712-dont-agent-phone)).

---

## 6.7 Screens for Agent Phone

Agent Phone is the desktop app agents keep running to pick up calls ([Part 2F](2f-agent-phone.md), [AGENT-PHONE.md](../AGENT-PHONE.md)). **You don't build the app.** You build the screens around it on your dashboard: signing an agent's computer in, the business's shared computers, shifts, the business's switches, and the notices. Everything here goes through your backend with your API key, like every other screen in this part.

```
 your dashboard (browser) ──► your backend ──► engine /v1/accounts/…   (API key: codes, devices, stations, shifts, settings)
                                    ▲
                                    └── webhooks: agent.device_*, agent.shift_*, station.*, agent.status, call.ring_declined

 Agent Phone (desktop) ───────────────────────► engine /v1/agent/…     (its own device token; you never see it)

```

The dashboard and the app never talk to each other. They meet in the engine: you start something (a pairing, a shift, a setting), the engine tells the app within a second, and the engine tells you (webhook) what the app did.

### 6.7.1 Who sees which screen

<table id="bkmrk-screen-agent-%28signed"><thead><tr><th>Screen</th><th>Agent (signed in)</th><th>Manager / owner</th><th>Section</th></tr></thead><tbody><tr><td>Download Agent Phone</td><td>✓</td><td>✓</td><td>[6.7.2](#672-download-agent-phone)</td></tr><tr><td>Connect Agent Phone (my computer)</td><td>✓ for themselves</td><td>✓ for any agent</td><td>[6.7.3](#673-connect-agent-phone)</td></tr><tr><td>My devices / an agent's devices</td><td>✓ their own</td><td>✓ all</td><td>[6.7.4](#674-devices)</td></tr><tr><td>Shared computers (add, settings, remove)</td><td>–</td><td>✓</td><td>[6.7.5](#675-shared-computers)</td></tr><tr><td>Start / end my shift on a shared computer</td><td>✓ for themselves</td><td>✓ for any agent</td><td>[6.7.6](#676-start-my-shift)</td></tr><tr><td>Agent Phone settings (the business's switches)</td><td>–</td><td>✓ owner</td><td>[6.7.7](#677-agent-phone-settings)</td></tr><tr><td>My status (Available / Away)</td><td>✓</td><td>✓ for any agent</td><td>[6.7.8](#678-status)</td></tr></tbody></table>

Your own login and roles decide this; the engine only knows your API key. **Always check on your backend** that the signed-in person may act for the agent ref they send.

### 6.7.2 Download Agent Phone

A page or a button on the agent's profile: "Download Agent Phone for Windows / macOS / Linux", with the short version of what it does ("rings on your computer even when this page is closed").

- Until releases are published from the engine (planned: [AGENT-PHONE-RELEASES.md](../AGENT-PHONE-RELEASES.md)), link to the installers the Ringmaster owner gives you.
- Planned: `GET {engine}/downloads/agent-phone/latest/{windows|macos|linux}` redirects to the newest installer, so the button never needs changing.
- Detect the visitor's system from the browser and show that button first; keep the other two as links.

### 6.7.3 Connect Agent Phone

**Where:** the agent's profile ("My calls" / "My computer"), and on a manager's agent page.

**Flow**

```
[Connect Agent Phone]  → your backend: POST /v1/accounts/{accountId}/agents/{ref}/devices/pairings   (2F K1)
                       ← { code: "482913", link: "ringmaster-agent://pair?code=482913&host=…", expiresAt }

┌──────────────────────────────────────────────┐
│ Connect Agent Phone                          │
│                                              │
│   [ Open Agent Phone ]   ← the link          │
│                                              │
│   or type this code in Agent Phone:          │
│          4 8 2   9 1 3                       │
│   Expires in 4:52 · [New code]               │
│                                              │
│ Don't have it yet? Download Agent Phone      │
└──────────────────────────────────────────────┘

```

1. The **Open Agent Phone** button is the `link`. On a computer with Agent Phone installed it opens the app and pairs it at once; elsewhere nothing happens, which is why the code is shown too.
2. Show the code large, in two groups of three, with a countdown to `expiresAt` (5 minutes). When it runs out, replace the code with "This code expired" and a **New code** button (a new `POST`).
3. Wait for [`agent.device_paired`](3-webhooks.md#agentdevice_paired--agentdevice_revoked) for that agent. Then close the dialog with "Connected: *Agent Phone on reception-pc*" and refresh the device list (6.7.4). Push it to the open page over your own socket; don't poll the engine.

**Do**

- Ask the person to **sign in again** (or confirm 2FA) if their session is old before showing a code. The code is what connects a computer to their calls.
- Show the code only to the person it's for, on screen. One code is for one agent.

**Don't**

- Don't send the code by SMS, e-mail or chat, and don't log it. It works once, for 5 minutes, but anyone holding it in that time can connect a computer to that agent.
- Don't create codes in bulk "for later": make one when the person clicks.

**Errors**: `404` agent not found. When pairing is refused at the app (shown in the app, not to you): `TOO_MANY_DEVICES` (the agent already has 5: remove one in 6.7.4), expired or used code.

### 6.7.4 Devices

**Where:** under Connect Agent Phone on the agent's profile, and on the manager's agent page.

`GET /v1/accounts/{accountId}/agents/{ref}/devices` (2F K2):

<table id="bkmrk-column-from-show-as--1"><thead><tr><th>Column</th><th>From</th><th>Show as</th></tr></thead><tbody><tr><td>Name</td><td>`name`</td><td>"Agent Phone on reception-pc"</td></tr><tr><td>System</td><td>`platform` + `appVersion`</td><td>"Windows · 0.2.0"</td></tr><tr><td>Last seen</td><td>`lastSeenAt`</td><td>"2 minutes ago" (relative, the viewer's time zone)</td></tr><tr><td>Where</td><td>`lastIp`</td><td>for managers only</td></tr><tr><td></td><td></td><td>**Remove**</td></tr></tbody></table>

**Remove** → confirm ("Asha's laptop will be signed out at once and stop ringing") → `DELETE …/devices/{deviceId}`(2F K3). The app goes back to its pairing screen within a second. Use it for a lost laptop, a person who left, or a device the agent doesn't recognise.

Keep the list current from [`agent.device_paired` / `agent.device_revoked`](3-webhooks.md#agentdevice_paired--agentdevice_revoked). Show `revoked` reasons in words:

<table id="bkmrk-reason-say-removed-%22"><thead><tr><th>`reason`</th><th>Say</th></tr></thead><tbody><tr><td>`REMOVED`</td><td>"Removed by *Juma*" (who clicked: your own audit)</td></tr><tr><td>`SIGNED_OUT`</td><td>"Signed out on the computer"</td></tr><tr><td>`UNUSED`</td><td>"Signed out: not used for 30 days"</td></tr><tr><td>`AGENT_REMOVED`</td><td>"Signed out: the agent was removed"</td></tr><tr><td>`TOKEN_REUSED`</td><td>**"Signed out for safety: this computer's sign-in was copied."** Alert the owner/manager; ask the agent to check the computer before connecting again</td></tr></tbody></table>

### 6.7.5 Shared computers

For a computer several agents use in turns (the reception PC). **Where:** a manager's "Shared computers" page.

<table id="bkmrk-action-endpoint-%282f-"><thead><tr><th>Action</th><th>Endpoint (2F K4)</th></tr></thead><tbody><tr><td>List, with who is on shift</td><td>`GET /v1/accounts/{accountId}/stations`</td></tr><tr><td>**Add shared computer**</td><td>`POST …/stations/pairings` → show the code and link exactly as in 6.7.3, with the note "Open Agent Phone **on the shared computer** and type this code"</td></tr><tr><td>Shift ends after N minutes without calls</td><td>`PATCH …/stations/{stationId}` `{"idleEndMin": 120}` (0 = never)</td></tr><tr><td>Shift ends at a time of day</td><td>`PATCH …/stations/{stationId}` `{"endAtTime": "17:30"}` (`""` = never)</td></tr><tr><td>End the current shift</td><td>`DELETE …/stations/{stationId}/shift`</td></tr><tr><td>Remove the computer</td><td>`DELETE …/stations/{stationId}` (signed out at once)</td></tr></tbody></table>

```
Shared computers                                        [Add shared computer]
┌──────────────┬──────────────────────────┬───────────────┬──────────┬────────────┐
│ Computer     │ On shift                 │ Ends if idle  │ Ends at  │            │
├──────────────┼──────────────────────────┼───────────────┼──────────┼────────────┤
│ Reception PC │ Asha · since 08:02       │ 120 min       │ 17:30    │ End · ⋯    │
│ Store PC     │ nobody                   │ 60 min        │ –        │ ⋯          │
└──────────────┴──────────────────────────┴───────────────┴──────────┴────────────┘

```

Keep it live from `station.paired`, `station.removed`, `agent.shift_started`, `agent.shift_ended`([Part 3](3-webhooks.md#agentshift_started--agentshift_ended)).

### 6.7.6 Start my shift

A shared computer rings for nobody until an agent starts a shift **from your dashboard**. Your login is the proof of who they are: there is no PIN in Agent Phone.

**Where:** the agent's dashboard home, near the top, while they are not on shift anywhere:

```
Start my shift on:  [ Reception PC (nobody) ▾ ]  [Start]

```

- `POST /v1/accounts/{accountId}/stations/{stationId}/shift` `{"agentRef": "<the signed-in agent>"}`.
- The computer switches to that agent's desk by itself within a second, and the agent becomes **Available**.
- If someone else is on shift there, ask first: "*Baraka* is on shift on Reception PC. Start yours instead?" Starting ends theirs.
- `409 SHIFT_BUSY`: "*Baraka* is on a call. Try again when it ends." A call is never cut.
- While on shift, replace the box with "On shift on *Reception PC* since 08:02 · **End my shift**" (`DELETE …/stations/{stationId}/shift`). The agent can also end it on the computer.
- A shift also ends by itself (idle time, end of day, another agent, the computer removed): `agent.shift_ended` with the `reason` tells you which. Show it to the agent next time they look ("Your shift on Reception PC ended at 17:30").

**Don't** let one agent start a shift for another unless the signed-in person is a manager.

### 6.7.7 Agent Phone settings

**Where:** the business's settings, a section "Agent Phone" (owner or manager). One `PATCH /v1/accounts/{accountId}`with all five switches together (they are replaced as a whole) and, if you show it, the recording notice ([Part 2A](2a-setup.md)):

```json
{ "agentPhone": { "showTeam": true, "showCallerNumbers": true, "allowDecline": true, "allowCallBack": true, "allowDialPad": true },
  "recording": { "mode": "LIVE_PARTS", "noticeText": "Mazungumzo haya yanarekodiwa kwa ajili ya ubora wa huduma.",
                 "noticeMediaId": null, "transcribe": true, "agentCallNotice": false } }

```

<table id="bkmrk-switch-label-to-show"><thead><tr><th>Switch</th><th>Label to show</th><th>What it means for agents</th></tr></thead><tbody><tr><td>`showTeam`</td><td>Show the team</td><td>Agents see who else in their departments is available, on a call or away</td></tr><tr><td>`showCallerNumbers`</td><td>Show caller numbers</td><td>Off: numbers appear as `+2557•••••250` everywhere in the app; names from your contacts still show</td></tr><tr><td>`allowDecline`</td><td>Agents may decline calls</td><td>Off: no Decline button; calls are assigned (big call centres)</td></tr><tr><td>`allowCallBack`</td><td>Agents may call people back</td><td>Call-back buttons on recent calls</td></tr><tr><td>`allowDialPad`</td><td>Agents may dial any number</td><td>The dial pad and "New call" in the app</td></tr><tr><td>`recording.agentCallNotice`</td><td>Say the recording notice on calls agents make</td><td>Off by default; turn on where the law asks people to be told</td></tr></tbody></table>

- The engine **enforces** every switch (a refused action answers `409`), not only the app. Your own screens must hide the same buttons (a browser desk's Decline, a call-back link).
- Agents' apps update within a second of saving (the engine sends `config`). Save on change and show "Saved".
- `recording` is replaced as a whole too: send the current policy with only `agentCallNotice` changed.

### 6.7.8 Status

Agents go **Away** and **Available** on your dashboard (`PUT …/agents/{ref}/status`, [2D I10](2d-agents.md#i10-set-an-agents-status)). Agent Phone shows the status and only offers **I'm back** when the agent is away; it never sets Away.

- Put the status switch where agents look all day (the top bar), and the team's statuses on the manager's wall.
- Keep both live from `agent.status`; an agent's status also changes by itself (on a call, wrap-up, missed rings, "Going on a break" when declining, starting a shift).

### 6.7.9 Caller names (your contacts URL)

If your app knows its customers, answer the engine's contacts URL ([Part 3 §3.4](3-webhooks.md#34-the-contacts-url)) and agents see "John Mushi · VIP customer" instead of a number, on the ring, the call, the queue and in search; flows can greet callers by name. The Ringmaster owner sets the URL once.

**Do**

- Check the signature, and look up only within the `accountRef` you're asked about (two businesses can share a customer's number).
- Answer within **1 second** for `?number=` (an index on the normalised number); slow answers are dropped and the call goes on without a name. `404` for unknown.
- For `?q=`: match the start of names and any part of the digits; return at most 10, best first.
- Keep `label` short (≤ 20 characters shows well) and `note` to one line the agent can read during a ring.

**Don't** put things in `note` the agent shouldn't see (balances, medical details) unless they need them on the call.

### 6.7.10 Notices from the engine

<table id="bkmrk-webhook-tell-how-age"><thead><tr><th>Webhook</th><th>Tell</th><th>How</th></tr></thead><tbody><tr><td>`agent.device_paired`</td><td>The agent (and their manager): "New computer connected: *Agent Phone on reception-pc*. Not you? Remove it."</td><td>In-app notice; e-mail if you e-mail security notices</td></tr><tr><td>`agent.device_revoked` with `TOKEN_REUSED`</td><td>Owner/manager: security alert (6.7.4)</td><td>Prominent, until read</td></tr><tr><td>`agent.device_revoked` (other reasons)</td><td>Update the device list</td><td>Quiet</td></tr><tr><td>`agent.shift_started` / `agent.shift_ended`</td><td>Shared computers page; the agent's home</td><td>Quiet</td></tr><tr><td>`station.paired` / `station.removed`</td><td>Shared computers page</td><td>Quiet</td></tr><tr><td>`call.ring_declined`</td><td>Manager's live board: "Asha declined (with a customer)"</td><td>Live counter; the reasons add up in [agent stats](#agents)</td></tr></tbody></table>

### 6.7.11 A browser desk next to Agent Phone

If you also keep the browser agent desk ([6.4](#the-agent-desk)), it must behave when the agent has Agent Phone open:

- Each browser tab gets its own identity from `POST …/agents/{ref}/lobby-token`; tabs and Agent Phone no longer push each other out.
- When a `ring` has `"audible": "phone"`, don't play a sound in the browser: show a quiet card "Ringing on Agent Phone" with Accept still there (whichever accepts first wins).
- Waiting callers can be answered from the queue with **Take** (`POST /v1/calls/{callId}/take`, [2D J9](2d-agents.md#j9-take-a-waiting-caller)), even while the agent is away.

### 6.7.12 Don't (Agent Phone)

1. Don't send pairing codes by message or keep them; show them once, on screen, to the right person.
2. Don't let a signed-in person pair, remove devices, start shifts or set switches for others unless your roles allow it.
3. Don't ignore `TOKEN_REUSED`: someone copied a sign-in.
4. Don't ring in the browser when the ring says `audible: "phone"`.
5. Don't keep a device token or a station token anywhere: your app never needs one.
6. Don't block a call on your contacts URL: answer fast or not at all.

# Appendix — Not for client apps

# Appendix — Not for client apps

[← Contents](README.md) · Previous: [Part 6 — Building the screens](6-screens.md)

What the engine has besides the client API, so client developers know what to ignore and what to ask the Ringmaster owner for.

---

## A. Engine-internal routes

These answer on the engine's host but are **not** part of the client API. They don't take the client app's key, and in production they are reachable only inside the voice box's network.

<table id="bkmrk-route-who-calls-it-w"><thead><tr><th>Route</th><th>Who calls it</th><th>What for</th></tr></thead><tbody><tr><td>`POST /internal/calls/inbound` · `POST /internal/calls/{callId}/turn`</td><td>The voice worker</td><td>Asks the engine "what next?" on each step of a call (worker token)</td></tr><tr><td>`GET /internal/audio/tts/{hash}` · `GET /internal/audio/media/{id}` · `POST /internal/tts/claim` · `POST /internal/tts/{hash}`</td><td>The voice worker</td><td>Audio for calls; turning texts into speech</td></tr><tr><td>`POST /hooks/livekit`</td><td>LiveKit</td><td>Room and participant events (signed by LiveKit)</td></tr><tr><td>`POST /hooks/resume/{token}`</td><td>A business's own system</td><td>The callback of a Send request block in "wait for callback" mode; the token is single-use and in the URL the block sent (see [Part 5, Send request](5-flow-reference.md#request))</td></tr><tr><td>`GET /media/{mediaId}/{hash}.mp3?t=…`</td><td>A browser</td><td>The signed `previewUrl` of an audio file ([Part 2B](2b-flows.md#e2-list-audio)); no key, expires</td></tr><tr><td>`/sim/*`</td><td>Developers, local only</td><td>The phone simulator; never enabled next to a real trunk</td></tr><tr><td>`/actuator/*`</td><td>Monitoring</td><td>Health and metrics</td></tr></tbody></table>

---

## B. What the Ringmaster owner sets up

The owner runs `ringmaster-admin` on the voice box. A client app depends on these; ask the owner for them.

### Client app

<table id="bkmrk-command-does-client-"><thead><tr><th>Command</th><th>Does</th></tr></thead><tbody><tr><td>`client-app create --name <app> [--webhook-url <url>] [--can-call-url <url>] [--features inbound,callouts,otp,ai] [--rate-limit 50]`</td><td>Creates the client app; prints the **API key** and the **webhook secret** once</td></tr><tr><td>`client-app edit <app> [--webhook-url <url|none>] [--can-call-url <url|none>] [--features …] [--rate-limit n]`</td><td>Changes them; offers a test webhook after a URL change</td></tr><tr><td>`client-app features <app> --set inbound,callouts,otp,ai,numbers`</td><td>Sets the features ([Part 1 §1.2](1-how-it-works.md#12-getting-access))</td></tr><tr><td>`client-app rotate-webhook-secret <app>`</td><td>New webhook secret, shown once; signatures use it from that moment — switch at once (refused events are retried for 24 h)</td></tr><tr><td>`client-app test-webhook <app>`</td><td>Sends `test.ping` and shows the answer</td></tr><tr><td>`client-app list` · `client-app show <app>`</td><td>Status, features, rate limit, URLs</td></tr><tr><td>`client-app suspend <app> --reason <text> [--hard]` · `client-app resume <app>`</td><td>Suspended: API calls get `403 CLIENT_SUSPENDED` and call-outs pause. With `--hard`, incoming calls are refused too; without it, callers are still answered</td></tr><tr><td>`client-app export <app> --out <file.zip>` · `client-app remove <app>` · `client-app purge <app>`</td><td>Leaving: export everything, remove (grace period), then delete for good</td></tr></tbody></table>

### API keys

<table id="bkmrk-command-does-key-rot"><thead><tr><th>Command</th><th>Does</th></tr></thead><tbody><tr><td>`key rotate <app>`</td><td>A new key (shown once); the old one keeps working until revoked, so both can be used during the switch</td></tr><tr><td>`key revoke <app> <prefix>`</td><td>Stops a key at once</td></tr><tr><td>`key list <app>`</td><td>Keys by prefix, created, last used and from which IP</td></tr></tbody></table>

### Numbers

<table id="bkmrk-command-does-numbers"><thead><tr><th>Command</th><th>Does</th></tr></thead><tbody><tr><td>`numbers register <e164> [--type REGULAR|TOLL_FREE]`</td><td>Adds a number the owner bought to the pool</td></tr><tr><td>`numbers allocate <e164> --client <app>`</td><td>Gives it to a client app (it shows as `ALLOCATED` in `GET /v1/numbers`)</td></tr><tr><td>`numbers unallocate <e164>`</td><td>Takes it back</td></tr><tr><td>`numbers trunk-setting [--ip <voice box IP>]`</td><td>What to set at the carrier so the number reaches the engine</td></tr><tr><td>`numbers list`</td><td>Every number, its status, client app and account</td></tr></tbody></table>

### Audit

`audit log [--client <app>] [--since 7d]` — who changed what (owner and client app changes to numbers, apps, keys).

---

## C. Versioning of this API

- Paths start with `/v1`. Within `v1`, the engine only **adds**: new endpoints, new optional request fields, new response fields, new enum values, new webhook types and new timeline events. Client apps must ignore fields and values they don't know.
- Anything that would break a client app (removing or renaming a field, changing a meaning) comes as `/v2`, with time to move.
- The script schema has its own `schemaVersion` ([Part 2A, A2](2a-setup.md#a2-script-schema)).

# Scripts & Blocks Reference

> **Client app developers: read [api/5-flow-reference.md](api/5-flow-reference.md) instead.** It is checked against the engine's code. This file is the engine's design notes (worker commands, execution, costs); some names here are older than the code (e.g. Condition `test.kind`, Go to `target`).

> The one system that decides what happens on **incoming calls**, **call-outs** (broadcasts, campaigns) and **AI** conversations. Built by customers in the client app's Voice Studio (block canvas). Companion to `SPEC.md`, `ENGINE.md`, `ARCHITECTURE.md`, `VOICE-WORKER.md`, `CLIENT-APP-ACTIONS.md`, `SCENARIOS.md`, `EXAMPLES.md`. **SIP trunk only (2026-11-12).** Blocks run as commands for the voice worker inside the call's LiveKit room; no AT XML. The Voice API version of this file is in `archive/`.

---

## 1. Concepts

A **script** is a graph of **blocks**. Each block does one thing and has named **exits** wired to the next block. An unconnected exit ends the call (with the account's goodbye if the call is still up).

<table id="bkmrk-script-direction-whe"><thead><tr><th>Script direction</th><th>Where it's used</th><th>Start block exits</th></tr></thead><tbody><tr><td>**Incoming**</td><td>Numbers page (one script per number)</td><td>`call` · Incoming call</td></tr><tr><td>**Outgoing**</td><td>Call-outs (broadcast, campaign, API `POST /calls`)</td><td>`out` · Person answered</td></tr><tr><td>**Shared**</td><td>Only through Transfer to script, from either direction</td><td>`in` · Transferred in</td></tr></tbody></table>

One direction per script. To reuse content in the other direction, duplicate the script, or put the shared part in its own script and use **Transfer to script**.

Not scripts: **OTP** (system message), **click-to-call** (live call), **callback** (a follow-up task).

### 1.1 Layers

```
Message type (Incoming / OTP / Broadcast / Campaign / Click-to-call)   → lane, recording rule, limits
  └─ Script version (blocks + settings)                                → what happens
      └─ Block (announcement, prompt, waiting room, exits)             → one step
          └─ Rendering (variables filled, text → cached audio)        → what the caller hears
              └─ Worker commands (play, ask, record, wait, connect, ai) → what the worker does in the room

```

### 1.2 How blocks become worker commands

<table id="bkmrk-command-used-by-play"><thead><tr><th>Command</th><th>Used by</th></tr></thead><tbody><tr><td>`play`</td><td>Say, announcements, AI disclosure, recording notice, Goodbye, system messages</td></tr><tr><td>`ask`</td><td>Menu, Collect number, Poll, Pick from list, Ask question (keys and/or speech)</td></tr><tr><td>`record`</td><td>Voicemail</td></tr><tr><td>`wait`</td><td>Waiting Room: Connect, Send request (wait), App action (wait)</td></tr><tr><td>`connect` / `hold`</td><td>Connect once an agent accepted; hold and transfer during the live call</td></tr><tr><td>`ai`</td><td>AI Agent</td></tr><tr><td>`hangup`</td><td>Goodbye, Opt out (after its message), limits</td></tr></tbody></table>

Rules (full protocol: `VOICE-WORKER.md`):

- A turn reply is never empty: at least one command, or the system goodbye.
- An **announcement** plays before the prompt and can't be interrupted; the prompt can (barge-in).
- A prompt can have **several parts** (text and audio mixed); the worker plays them in order.
- Each turn has a number; a late or repeated result for an old turn changes nothing (the engine re-sends the current commands).

---

## 2. Messages

Same shape everywhere:

```json
{ "src": "text",    "text": "Habari {name}", "lang": "sw" }
{ "src": "record",  "media": "med_123" }
{ "src": "upload",  "media": "med_456" }
{ "src": "library", "media": "sys_hold_default" }

```

(The Swahili example reads "Hello {name}".)

- `text` → filled, turned into words where needed (§3), then turned into audio by the engine's TTS and **cached**: the same text and voice is generated once and reused by every call (ARCHITECTURE §5.2). Max 500 characters.
- **Voice and speed** (who speaks, how fast). The client sets them where they want; the nearest wins: 
    1. on the message: `{ "src": "text", "text": "…", "voice": "cedar", "speed": 1.2 }`
    2. on the block: `"voice": "cedar", "speed": 0.9` next to `type` (applies to every message the block says, including its retries and system lines)
    3. in the script: `settings.voice` (`"cedar"` or per language `{ "sw": "marin", "en": "cedar" }`), `settings.speed` (number or per language)
    4. the engine default (`ringmaster.tts.voice`, speed 1.0)
- Speed runs 0.7 (slower) to 1.5 (faster), rounded to steps of 0.05; 1.0 is normal.
- Voices (OpenAI): female-sounding `marin`, `coral`, `shimmer`, `sage`; male-sounding `cedar`, `ash`, `echo`, `ballad`, `verse`.
- The audio cache keys on voice and speed (`cedar@1.2`), so changing either makes new audio; normal speed keeps the plain name.
- The AI Agent sets its own: `agent.voice` and `agent.speed` (`agent.openaiVoice` is the older name for the voice).
- `record` / `upload` / `library` → the worker plays the file; it must be READY.
- A prompt is a message or a list of messages: `"msg": [ {…text…}, {…library…} ]`.
- Missing media at call time → system "sorry" message, then goodbye; alert.

---

## 3. Variables and metadata

**Variables** are typed values that blocks read aloud, send, or test. Written as `{name}`.

<table id="bkmrk-group-examples-avail"><thead><tr><th>Group</th><th>Examples</th><th>Available</th></tr></thead><tbody><tr><td>System</td><td>`{caller}`, `{number}`, `{callId}`, `{date}`, `{time}`, `{key}` (last key)</td><td>Everywhere</td></tr><tr><td>Contact / call-out</td><td>`{name}`, `{amount}`, `{order}`… (declared by the script; filled per person from the client app, a file, or the API)</td><td>Outgoing scripts</td></tr><tr><td>Collected</td><td>`{input.account}` from Collect number; `{choice.*}` from Pick from list; `{answer.problem}` from Ask question; `{heard}` (last thing the caller said); `{lang}`. Spoken, `{input.*}` and `{poll.*}` digits are read one by one in words ("moja mbili tatu nne"); requests get the raw digits</td><td>After that block</td></tr><tr><td>Reply</td><td>`{status}`, `{ref}`… mapped from a Send request reply; `{action.<block>.*}` from an App action result</td><td>After that block</td></tr><tr><td>AI</td><td>`{ai.patient_name}`, `{ai.date}`, `{ai.amount}`, `{ai.language}`</td><td>After the AI Agent</td></tr><tr><td>Live facts</td><td>`{business.open}`, `{business.opens_at}`, `{agents.online}`, `{queue.wait}`</td><td>Everywhere (read live)</td></tr></tbody></table>

- Incoming calls only know `{caller}` (+ the client app's contact if found). An Incoming script must not depend on call-out variables.
- Missing value at runtime → empty string + warning in the call trail (validation prevents most cases; the call-out form refuses people without values).
- Variable types (`vars[].type`): `text` (default), `money`, `number`, `date`, `digits`. Spoken forms are made by the engine for the call's language before TTS: `money` 45000 → "shilingi elfu arobaini na tano" (sw) / "forty-five thousand shillings" (en); `digits` 0712… → read one by one; `date` → "tarehe 20 Novemba" (sw). In Send request URLs/bodies and App action params the raw value is used. **\[VERIFY: whether the TTS voice reads digits well enough to skip the conversion\]**

**Metadata**: opaque JSON the client attaches to a call or call-out (`{"orderId": 991}`). Never spoken, never sent to Send request unless mapped into a variable. Echoed back in every event as `meta.client`.

---

## 4. Script document

```json
{
  "scriptId": "scr_debt", "version": 3, "direction": "OUTGOING",
  "settings": { "record": "policy", "maxCallMin": 15, "language": "sw", "voice": { "sw": "marin", "en": "marin" } },
  "vars": [ { "name": "name", "type": "text" }, { "name": "amount", "type": "money" } ],
  "start": { "out": "say_1" },
  "blocks": {
    "say_1":  { "type": "say", "msg": { "src": "text", "text": "Habari {name}. Deni lako ni {amount}." }, "x": { "next": "menu_1" } },
    "menu_1": { "type": "menu", "msg": { "src": "text", "text": "Bonyeza 1 au sema 'lipa' kulipa sasa, 2 kuongea na mhasibu, 9 kujiondoa." },
                "options": [ { "k": "1", "id": "pay",   "label": "Lipa",     "words": ["lipa", "kulipa", "malipo"] },
                             { "k": "2", "id": "staff", "label": "Mhasibu",  "words": ["mhasibu", "mtu", "kuongea"] },
                             { "k": "9", "id": "out",   "label": "Jiondoe",  "words": ["jiondoa", "acha"] } ],
                "speech": { "match": "words" }, "timeoutSec": 8,
                "x": { "pay": "pay_1", "staff": "connect_1", "out": "optout_1", "noInput": "bye_1", "notUnderstood": "menu_1" } },
    "pay_1":  { "type": "app_action", "action": "pay", "params": { "amount": "{amount}", "wallet": "M-Pesa" },
                "wr": { "preset": "Payment" }, "x": { "paid": "bye_2", "failed": "sms_1", "timeout": "sms_1" } }
  }
}

```

Each block: `type`, block settings, optional `before` (announcement message), optional `wr` (Waiting Room), `x` (exit → block id; missing = end). (The Swahili lines read: "Hello {name}. Your debt is {amount}." · "Press 1 or say 'pay' to pay now, 2 to talk to the accountant, 9 to opt out.")

---

## 5. Blocks (complete list: 17)

⚠ = needs approval (the whole script version).

### Speak

<table id="bkmrk-block-settings-exits"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th><th>Command</th></tr></thead><tbody><tr><td>**Say / Play**</td><td>message (one or several parts), `interruptible` (default off)</td><td>`next`</td><td>`play`</td></tr></tbody></table>

### Ask: keys, speech, or both

All Ask blocks share one rule set:

- **Keys always work.** A block also **listens for speech** when it has a `speech` setting. In the Studio this follows Twilio's pattern: connecting the block's "said something" exit turns speech on.
- **Barge-in**: the caller can press a key or start talking during the prompt; the prompt stops.
- **Type-ahead**: keys pressed during an interruptible Say carry into the next Ask.
- **No input** after `timeoutSec` (counted from the end of the prompt) → retry (up to `retries`, max 3) → `noInput`.
- **Not understood** (speech that matches nothing) → "Samahani…" ("Sorry…") retry → `notUnderstood`. Only on blocks that listen for speech.
- Speech is cut by the worker (VAD) and transcribed by the engine (`gpt-transcribe`, the block's language, the options' words as hints). The text is saved as `{heard}` and in the timeline (`SPEECH`).

<table id="bkmrk-block-settings-exits-1"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th><th>Notes</th></tr></thead><tbody><tr><td>**Menu**</td><td>prompt, `options` \[{`k` key, `id`, `label`, `words` \[spoken words\], `lang`}\], `speech` {`match`: `words` | `smart`, `minConfidence`}, `timeoutSec`, `retries`</td><td>each option `id`, `noInput`, `notUnderstood`</td><td>Keys pick by `k`; speech by `words` (exact or close), or with `smart` by the AI (`gpt-6-luna`), which understands free speech ("nataka kuongea na mtu wa malipo", "I want to talk to someone about payments"). An option with `lang` (`sw`, `en`) also switches the call's language (`{lang}`). Replaces the AI Router (removed 2026-11-12).</td></tr><tr><td>**Collect number** ⚠</td><td>question, save as `{input.x}`, min/max digits, finish key `#`, pattern, `speech` (off by default)</td><td>`ok`, `failed`</td><td>Keys by default: spoken account numbers are where speech recognition errs most. With `speech`, spoken numbers ("sifuri saba moja…", "zero seven one…") become digits.</td></tr><tr><td>**Poll**</td><td>question, answers 1–5 with optional `words`, save as</td><td>`next`, `noInput`</td><td></td></tr><tr><td>**Pick from list**</td><td>intro, list source (a Send request reply `items[]`, or inline), label template, per page (≤ 9, `0` = more)</td><td>`chosen` (sets `{choice.id}`, `{choice.label}`, `{choice.*}`), `empty`, `noInput`</td><td>With speech, the caller can say the item's name.</td></tr><tr><td>**Ask question** *(new)*</td><td>question, save as `{answer.x}`, `maxSec` (5–60, default 20), language</td><td>`answered`, `silence`</td><td>"Tell us your problem." The caller's words become text in `{answer.x}` and the call **carries on** (Voicemail ends it). Sent to Send request, App actions or the summary.</td></tr></tbody></table>

### Connect

<table id="bkmrk-block-settings-exits-2"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th><th>Command</th></tr></thead><tbody><tr><td>**Connect to department**</td><td>department, record On/Off, Waiting Room. Whether members' **mobiles** ring too is the number's `ringMode` (D51), not a block setting</td><td>`noAnswer` + Waiting Room option exits</td><td>`wait`, then `connect`</td></tr><tr><td>**Voicemail**</td><td>prompt, max length (30/60/120 s), beep, finish key</td><td>— (ends)</td><td>`play` + `record`</td></tr><tr><td>**Schedule call** *(new, SCHEDULE-CALL.md)*</td><td>Outgoing script + pinned version; time: `after` (30m, 1h, 1d), `at` (17:00, tomorrow 09:00) **or** `ask` (the caller says it: "kesho saa tatu asubuhi"); confirm; vars for the later call; tries</td><td>`scheduled`, `notUnderstood`, `declined`, `refused`</td><td>books a one-person call-out; says the time back in Kiswahili time</td></tr><tr><td>**AI Agent** ⚠</td><td>see §8</td><td>`goal`, `human`, `vm`, `cb`, `nohelp`, `time`, `err`</td><td>`ai`</td></tr></tbody></table>

### Integrate

<table id="bkmrk-block-settings-exits-3"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th></tr></thead><tbody><tr><td>**Send request** ⚠</td><td>method, URL, params, headers, body (JSON / form / raw / none), auth (Signature default, Bearer, Basic, API key, none), reply mapping, timeout, mode</td><td>Send and continue: `next` · Wait for reply / Wait for callback: `success`, `failure`, `timeout` (+ Waiting Room options)</td></tr><tr><td>**App action**</td><td>`action` (a name the client app registered), `params`, message before waiting, Waiting Room (if the action waits)</td><td>Doesn't wait: `next` · Waits: each registered outcome + `failed` + `timeout` (+ Waiting Room options)</td></tr></tbody></table>

App action replaces "Textify action" and "Pay now": the engine has no built-in SMS or payment actions; each client app registers its own (`CLIENT-APP-ACTIONS.md`). An action the app marks `risky` (e.g. pay) makes the block ⚠.

Send request details:

- Request: method + URL with variables; header `X-Voice-Signature` (HMAC-SHA256 of timestamp + body with the account's signing secret), `X-Voice-Timestamp`, `X-Voice-Idempotency-Key` (= callId + block).
- Body variables filled per call; `meta` object always added (`callId`, `callOutId`, `block`, `meta.client`).
- Reply: 2xx = success (empty 200 = success, nothing mapped); 4xx/5xx = failure; no answer within timeout (default 10 s, max 15 s while the caller waits) = timeout. JSON paths mapped to variables (`data.status` → `{status}`).
- HTTPS only; DNS-resolved private / loopback / link-local / metadata IPs blocked; no redirects; response ≤ 64 KB.
- Send and continue: queued, retried up to 24 h; never delays the caller.
- **Wait for callback** (slow systems): the body includes `{callback_url}`, a one-time signed URL for this call + block. The caller waits in the Waiting Room until the business POSTs the result there (mapped like a reply) or the give-up time passes. If the caller already hung up, the result is still stored and reported (`call.block`).
- Reply conveniences: a top-level `say` string can be spoken as-is (`{reply.say}`, made into audio at the turn); a top-level `items` array feeds **Pick from list**. Recommended reply contract: `EXAMPLES.md` §0.1.
- Secrets (tokens, passwords) stored encrypted; never shown again in full.

### Logic

<table id="bkmrk-block-settings-exits-4"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th></tr></thead><tbody><tr><td>**Condition**</td><td>Business hours · Called by us recently (7 days) · Contact exists in the client app · **Answered by** (outbound: person / machine, from answering-machine detection) · Variable test (equals, not equals, &gt;, &lt;, in, matches, exists, empty). Later: agents online, queue length, caller in a list, percentage split</td><td>Business hours: `open`, `closed` · Answered by: `person`, `machine` · others: `yes`, `no`</td></tr><tr><td>**Go to**</td><td>target block, `maxLoops` (default 3)</td><td>— (jumps; after `maxLoops` jumps from this block in one call → its `tooMany` exit, or the call ends)</td></tr><tr><td>**Transfer to script** *(new)*</td><td>script ref + **pinned version**, `inputs` {name: template}</td><td>the target's **return exits** (e.g. `done`, `failed`)</td></tr></tbody></table>

Transfer to script works like a function call into a **shared script** (D52):

- A shared script has direction `SHARED` and start exit `in`. It can't be put on a number or a call-out.
- It declares `inputs` (e.g. `["account"]`); the Transfer block must fill each one (checked at publish). Inside, they are `{in.account}`; system variables and live facts work as usual.
- It ends at **Return** blocks (`{"type": "return", "exit": "done"}`); the set of return exits is the script's interface and shows on the Transfer block as exits. Variables it sets (`{input.*}`, `{answer.*}`, reply variables) are visible to the caller after the return.
- Pinned version (editing the shared script changes nothing until the pin moves), nesting ≤ 5, and no script may reach itself (checked at publish).

### End

<table id="bkmrk-block-settings-exits-5"><thead><tr><th>Block</th><th>Settings</th><th>Exits</th><th>Notes</th></tr></thead><tbody><tr><td>**Opt out**</td><td>confirmation message</td><td>`next`</td><td>Adds `{caller}` to the account's do-not-call list</td></tr><tr><td>**Return**</td><td>`exit` (name)</td><td>—</td><td>Shared scripts only: back to the Transfer block, through its exit of that name</td></tr><tr><td>**Goodbye**</td><td>message</td><td>—</td><td>Ends the call</td></tr></tbody></table>

---

## 5a. Outcome tag &amp; sensitive flag (any block)

- **Outcome tag** (optional, block setting "Count as"): when a call reaches the block, the tag is added to the call (`paid`, `booked`, `promised`, `wants_callback`…). Used by call-out funnels and conversion. Automatic tags: App action outcomes the client app maps with `countAs` (e.g. `paid`), Opt out → `opted_out`, AI Agent goal → `goal`, Answered by machine → `machine`.
- **Sensitive** (Collect number): the value is usable in the call (e.g. sent to a verify request) but stored and shown as ••••, never in exports or webhooks. OTP codes are always sensitive. A sensitive Collect never listens for speech (no transcript of a PIN).

## 6. Announcement (any block)

- Optional message that plays **once when the block starts** ("Mazungumzo haya yanarekodiwa…", "This conversation is being recorded…"; "Tafadhali sikiliza kwa makini", "Please listen carefully").
- Added from block settings (**+ Add announcement**). Text, record, upload or library.
- Not played again when the block repeats after a wrong key.
- Plays before the prompt and can't be interrupted.

Automatic, not editable per block:

- **Recording notice**: once per call, right before the first recorded part (account wording).
- **AI-voice disclosure** (D53): OpenAI's usage policy requires telling callers that a voice is AI-generated, and every text prompt uses OpenAI's voice. So a call that will play generated voice starts with one short line, default "Habari, unazungumza na huduma ya kidijitali ya {business}." ("Hello, you're speaking with {business}'s digital service."), editable per account (`settings.aiDisclosure`). Played once, before the first generated prompt; skipped when the call plays only recorded audio. The AI Agent adds nothing extra.

---

## 7. Waiting Room (Connect, Send request "wait", App action "wait")

What the caller hears and can do **while the block is busy**. It runs in the call's room: the worker plays it, the engine decides when it ends.

<table id="bkmrk-setting-meaning-pres"><thead><tr><th>Setting</th><th>Meaning</th></tr></thead><tbody><tr><td>Preset</td><td>Queue (Connect) · Quick check (Send request) · Payment (App action) · Custom</td></tr><tr><td>Start message</td><td>Plays once when waiting starts ("Tafadhali subiri…", "Please wait…")</td></tr><tr><td>Background</td><td>Hold music (library or upload)</td></tr><tr><td>Updates every N s</td><td>Rotating messages ("Bado tunashughulikia…", "We're still working on it…")</td></tr><tr><td>Queue position</td><td>Connect only: "Wewe ni wa 3 kwenye foleni" ("You are 3rd in the queue"), refreshed when it changes</td></tr><tr><td>Offer options after N s</td><td>Keys → exits: Voicemail, Call me back, Back to menu… (appear as ⏳ exits on the block)</td></tr><tr><td>Give up after N s</td><td>→ `noAnswer` / `timeout` / `failed` exit</td></tr></tbody></table>

How it runs:

```
engine → worker: wait { start, music (loop), messages [{audio, everySec}], keys allowed after N s }
worker: plays start, loops music, plays updates; keys → result
engine, when something changes:
  work done (agent accepted, reply arrived, outcome reported) → wake worker → next block's commands
  queue position changed                                       → wake worker → new wait (fresh position message)
  give-up timer (JobRunr) fires                                → wake worker → give-up exit
  option key (allowed only after "offer options after N s")    → that option exit

```

- **Accept is instant**: the caller is already in the room, so an agent who accepts is talking within a second (the Voice API needed up to 15 s).
- The Waiting Room says only what the author writes: announce the option keys in `wr.start` or `wr.updates` (e.g. "Bonyeza 9 kuacha ujumbe", "Press 9 to leave a message"). Options with neither give the warning `WAIT_OPTIONS_UNANNOUNCED`.

---

## 8. AI

### 8.0 AI language

Each AI Agent, and each Ask block with speech, has a **language setting** chosen by the customer:

- **Fixed**: listen and speak only the listed language(s), e.g. `["sw"]` (Kiswahili only) or `["sw","en"]`; a caller speaking another language is answered in the first one.
- **Auto**: detect the caller's language from their speech and answer in it, switching if the caller switches.

```json
"language": { "mode": "FIXED", "languages": ["sw"] }      // or { "mode": "AUTO", "default": "sw" }

```

The detected language is saved as `{ai.language}` (and on the call), so later blocks and the client app know it. With AUTO and sw/en it also sets `{lang}`, so later system messages follow the caller.

### 8.1 Smart matching (Ask blocks)

- Menu, Poll and Pick with `"speech": {"match": "smart"}`: after word matching fails, the engine asks the AI which option the caller meant, given the options' labels and words. Answer below `minConfidence` (default 0.5) → `notUnderstood`.
- `{heard}` holds the text; the timeline records `MATCH {option, by: key | words | smart, confidence}`.
- Without an OpenAI key (`openai.api-key=sim-not-set`) the engine uses a simulated adapter: word matching only; the turn driver sends speech as text.

### 8.2 AI Agent

Designed in the **Agent canvas** (double-click the block).

```json
{
  "goal": "Book an appointment", "persona": "…", "voice": "marin",
  "guard": "Never promise prices not in the knowledge base", "maxMin": 5,
  "kb": { "items": [ { "q": "Saa za kazi", "a": "…" } ], "docs": ["bei_2026.pdf"], "site": "https://…" },
  "fields": [ { "name": "patient_name", "type": "Text", "desc": "Jina lako ni nani", "req": "Required" },
              { "name": "date", "type": "Date", "req": "Required" } ],
  "tools": [ { "type": "Use saved request", "name": "book_appointment", "req": "request_1" },
             { "type": "App action", "name": "send_confirmation", "action": "send_sms", "params": { "text": "Miadi yako ni {ai.date}" } } ],
  "start": "s1",
  "anytime": [ { "kind": "person", "to": "s7" }, { "kind": "upset", "to": "s7" } ],
  "stages": {
    "s1": { "type": "greet", "say": "Habari! Nikusaidie nini leo?", "leave": [ { "kind": "always", "to": "s2" } ] },
    "s2": { "type": "understand", "leave": [ { "kind": "question", "to": "s3" }, { "kind": "intent", "to": "s4" } ] },
    "s3": { "type": "answer", "kb": true, "leave": [ { "kind": "always", "to": "s2" } ] },
    "s4": { "type": "collect", "collect": ["patient_name", "date"], "leave": [ { "kind": "done", "to": "s5" } ] },
    "s5": { "type": "act", "actions": ["book_appointment", "send_confirmation"], "leave": [ { "kind": "success", "to": "s6" }, { "kind": "failed", "to": "s7" } ] },
    "s6": { "type": "confirm", "leave": [ { "kind": "always", "to": "exit:goal" } ] },
    "s7": { "type": "handover", "ho": {
              "ok":     { "say": "Nakuunganisha na mhudumu sasa hivi.", "to": "exit:human" },
              "busy":   { "say": "Wahudumu wote wako kwenye simu…",     "to": "exit:human" },
              "none":   { "say": "Tutakupigia ndani ya saa moja.",       "to": "exit:cb" },
              "closed": { "say": "Tumefunga sasa… acha ujumbe.",         "to": "exit:vm" } } }
  }
}

```

(The Swahili reads: "What are your working hours" · "What is your name" · "Your appointment is on {ai.date}" · "Hello! How can I help you today?" · "I'm connecting you to an agent right now." · "All agents are on calls…" · "We'll call you back within an hour." · "We're closed now… leave a message.")

<table id="bkmrk-part-meaning-stage-t"><thead><tr><th>Part</th><th>Meaning</th></tr></thead><tbody><tr><td>Stage types</td><td>Greet, Understand, Answer from knowledge, Collect details, Take action, Confirm, Handover, Custom</td></tr><tr><td>Stage settings</td><td>instruction, says on entering, fields to collect, allowed actions, may answer from knowledge, **leave when** rows</td></tr><tr><td>Leave-when kinds</td><td>Then (always), Asks a question, Wants to book/act, All info collected, Action succeeded, Action failed, Asks for a person, Caller upset</td></tr><tr><td>Anytime rules</td><td>Checked every turn from any stage (asks for a person, caller upset)</td></tr><tr><td>Knowledge base</td><td>Inside the agent: facts/FAQs, documents, website page. Used only in stages that allow it. Never invent</td></tr><tr><td>Fields</td><td>Become `{ai.*}` variables for later blocks</td></tr><tr><td>Tools</td><td>**Use saved request** (reuses a Send request block of the same script: URL, auth, reply mapping), **App action** (any action the client app registered; a waiting action's outcome is the tool result), Transfer to department. The AI uses the result to keep talking</td></tr><tr><td>Live facts</td><td>`{business.open}`, `{business.opens_at}`, `{agents.online}`, `{queue.wait}` from the system</td></tr><tr><td>Handover</td><td>Checks live facts: open + free → human · open + busy → human (queue) · no agents → call me back · closed → leave a message</td></tr><tr><td>Exits</td><td>`goal`, `human`, `vm` (leave a message), `cb` (call me back), `nohelp`, `time`, `err`: ordinary exits that may lead to **any** block</td></tr></tbody></table>

How it runs:

- The engine sends `ai {instructions, voice, tools, stage, language}`; the worker runs a LiveKit Agents session with the OpenAI Realtime model (`gpt-realtime-2.1`; `gpt-live-1` to be tested). Tool calls come to the engine as turn results; the engine runs them and replies with the result. Stage changes update the session's instructions. ARCHITECTURE Appendix A.1.
- Tools the model gets: `leave_stage(kind)` (the stage's non-automatic kinds + anytime kinds), `save_field(name, value)` in Collect stages (→ `{ai.<name>}`; all collected → `done`), `search_knowledge(query)` in Answer stages, the stage's tools, `set_language(code)` with AUTO language, `handover()`, `end_call(reason: goal_done | caller_done)`. A Take-action stage leaves `success` once all its actions worked, `failed` at the first failure.
- Handover uses live facts of the department behind the `human` exit: closed → `closed`, nobody online → `none`, someone free → `ok`, all busy → `busy`; each row `{say, to}` (defaults: ok/busy → human, none → cb, closed → vm).
- Limits: `maxMin` (1–30, default 5) → `time`; AI not starting within 20 s or failing → `err`. After the conversation: transcript (speaker-separated) + summary + collected fields → `summary.ready`.
- Knowledge: `kb.items` (Q/A), `kb.docs` (ids from `POST /v1/accounts/{id}/kb-docs`: .txt, .md, .pdf with text, ≤ 10 MB), `kb.site` (https page, read once when the version is saved). Built per script version in the background; searched with Postgres full-text (all words first, then any word).

Rule of thumb for tools vs exits: if the AI **needs the result to keep talking** (check balance, book) → a tool inside the agent; if the AI's part is **finished** (transfer, pay, goodbye) → an exit to a block.

---

## 8a. Block settings (exact JSON)

<table id="bkmrk-block-settings-say-m"><thead><tr><th>Block</th><th>Settings</th></tr></thead><tbody><tr><td>`say`</td><td>`msg` (message or list), `interruptible`</td></tr><tr><td>`menu`</td><td>`msg`, `options` \[{`k`, `id` (default = `k`), `label`, `words`, `lang`}\], `speech` {`match`: words | smart, `minConfidence`, `language`} or absent, `timeoutSec` (8), `retries` (2)</td></tr><tr><td>`collect`</td><td>`msg`, `saveAs` → `{input.<saveAs>}`, `minDigits`/`maxDigits` (1–20), `finishKey` (#), `pattern` (regex), `sensitive`, `retries`, `timeoutSec`, `speech` (bool, off; never with `sensitive`)</td></tr><tr><td>`poll`</td><td>`msg`, `answers` (1–5), `saveAs` → `{poll.<saveAs>}` (default block id), `labels` \[{k, label, words}\], `speech`</td></tr><tr><td>`pick`</td><td>`source` (id of a request block that waits for its reply, or `inline` + `items`), `msg` (intro), `label` template over item fields (`{label}` default), `perPage` 1–9 (5), `speech`</td></tr><tr><td>`ask`</td><td>`msg`, `saveAs` → `{answer.<saveAs>}`, `maxSec` (5–60, 20), `language`, `useMenuWords` (bool, off: when the Menu just before was answered by voice in 6+ words, those words are the answer and the question is skipped)</td></tr><tr><td>`connect`</td><td>`department`, `record`, `wr`</td></tr><tr><td>`schedule`</td><td>`script` + `version` (Outgoing), exactly one of `after` / `at` / `ask` {msg}, `confirm` (true with `ask`), `vars` {name: template} → the later call (plus `{scheduled.said}`, `{scheduled.from}`, `{scheduled.note}`), `note`, `maxTries` (1–3, 2), `retryGapSec` (≥ 300), `msg` (after booking; may use `{schedule.when}`). Rules: 5 min–30 days ahead, quiet hours move it (and the read-back says so), a new booking for the same caller and script replaces the old one, at most 3 pending per caller</td></tr><tr><td>Ask blocks' `later` exit</td><td>Menu, Collect, Poll, Pick: words meaning "not now" (baadaye, busy, later, sina muda, niko kazini…, plus `laterWords`) take `later` when it is wired; usually to a Schedule call</td></tr><tr><td>`voicemail`</td><td>`msg`, `maxSec` (30/60/120), `beep` (true: one beep to start; two short beeps `warnBeforeEndSec` before the limit; one long beep when time is up. Only the caller hears them, they are not in the recording), `warnBeforeEndSec` (default 10, or a third of a short `maxSec`; 0 = no warning), `finishKey`</td></tr><tr><td>`request`</td><td>`url` (https; `{vars}` URL-encoded), `method` (POST default), `params` {name: template}, `headers` {name: template} (`X-Voice-*`, Host, Content-\* ignored), `body` {`type`: json (default) | form | raw | none, `value`}, `auth` {`type`: signature (default) | bearer `token` | basic `username`+`password` | apikey `header`+`value` | none}, `map` {"json.path\[0\]": "varName"}, `timeoutSec` 1–15 (10), `mode` continue | reply (default) | callback, `wr`</td></tr><tr><td>`app_action`</td><td>`action`, `params` {name: template}, `msg` (spoken before waiting), `wr`</td></tr><tr><td>`condition`</td><td>`test`: hours | recent | contact | answeredBy | var {`var`, `op`, `value`}</td></tr><tr><td>`goto`</td><td>`to`, `maxLoops`</td></tr><tr><td>`transfer`</td><td>`script` (ref), `version`, `inputs` {name: template}</td></tr><tr><td>`return`</td><td>`exit`</td></tr><tr><td>`optout`, `goodbye`</td><td>`msg`</td></tr></tbody></table>

Behaviour:

- **Signing**: when the account has a request signing secret every request carries `X-Voice-Timestamp`, `X-Voice-Signature` = hex HMAC-SHA256(secret, timestamp + "." + body) (same scheme as webhooks), `X-Voice-Idempotency-Key` = `{callId}:{block}:{visit}`, `X-Voice-Call-Id`. JSON bodies get `meta` {callId, callOutId, block, client}; form bodies get `meta.callId`, `meta.block`.
- **Reply**: 2xx = `success`; other status, redirect (never followed), &gt; 64 KB, blocked address or network error = `failure`; no reply in time = `timeout`. `{reply.code}` is always set; top-level `say` → `{reply.say}`; top-level `items` feeds Pick from list.
- **Waiting**: a reply within ~1 s is used at once, with no Waiting Room; otherwise the caller goes to the Waiting Room and the engine wakes the worker when the reply arrives.
- **Wait for callback**: the system posts the result to `{callback_url}` (`POST /hooks/resume/{token}`, one-time, valid until the give-up time). `"result"` (or `"status"`) = failure / failed / error / declined / cancelled → `failure` exit, anything else → `success`; the body is mapped with `map`. After the caller left, the result is stored and sent as `call.block` with `late: true`. Answers: 200, 404 unknown, 409 already answered, 410 expired.
- **Send and continue**: queued (stored encrypted), retried on 5xx / 408 / 429 / timeout / network errors at 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h… for up to 24 h; a final failure sends `request.failed`.
- **Secrets**: `auth.token`, `auth.password`, `auth.value` are encrypted when the version is saved (`enc:` prefix) and decrypted only to send.
- **App actions**: `CLIENT-APP-ACTIONS.md` (event, answer, outcomes, timeouts).
- **Sensitive Collect**: the value is kept encrypted in the call state, readable by later blocks (`{input.pin}`), shown as `••••` in keys, timeline, webhooks and any spoken-text log.

---

## 9. Validation (on save / before publish)

Loops are allowed: any exit may point back to an earlier block or to the same block ("press 0 to repeat", story → back to the menu). The loop guard ends the call (`LOOP_GUARD`) only after `maxBlockVisits` (25) blocks **in a row without the caller pressing a key, speaking or recording**, so a broken `say → goto → say` loop stops, while a caller going round a menu is never cut off. Call length is capped separately (`maxCallMinutes`).

1. Start exits exist for the direction; every exit points to an existing block or is intentionally empty.
2. ≤ 60 blocks; menus ≤ 10 options; retries ≤ 3; text ≤ 500 characters.
3. Variables exist in scope: call-out variables only in Outgoing scripts; `{input.x}` after its Collect; `{answer.x}` after its Ask question; reply variables after their Send request; `{ai.*}` after the AI Agent.
4. Media READY; departments and saved requests belong to the account; **App actions exist in the client app's list with every required parameter** (`CLIENT-APP-ACTIONS.md` §4).
5. Send request: HTTPS URL, valid JSON body, auth complete.
6. Speech: a Menu, Poll or Pick with `speech` and `match: words` needs `words` on every option (warning `OPTION_WITHOUT_WORDS`); a sensitive Collect can't listen for speech.
7. Transfer to script: the target version exists, is runnable and `SHARED`; every declared input is filled; the block's exits match the target's return exits; no cycle; nesting ≤ 5. Return blocks only in `SHARED` scripts.
8. AI Agent: start stage set, every leave-when connected or intentionally empty, at least one exit reachable.
9. Approval flag computed: any ⚠ block → version status `Waiting approval`; else `Ready`.
10. Text to audio: every fixed text is listed in the version's `texts` so its audio is made at publish (ARCHITECTURE §5.2).

---

## 10. Versioning &amp; approval

- **Draft** (editable) → **Publish** → immutable version `vN` with status `Ready` or `Waiting approval` → `Approved` / `Rejected (reason)`.
- Numbers, call-outs and Transfer to script pin a version. Editing creates `vN+1`; running call-outs keep `vN`.
- A call-out on a version waiting approval is saved and starts automatically when approved.
- Rollback = point the number to an older approved/ready version.
- Approval lives in the client app; the engine only stores versions the app pushes as runnable (plus drafts for test calls to verified test numbers).

---

## 11. Execution (engine)

- Every result from the worker is one **turn**: `POST /internal/calls/{callId}/turn` (VOICE-WORKER §3). The engine locks the call, applies the result to the block that asked, runs blocks until one needs the caller, and answers with commands.
- Call **context** (variables, retries, visits, current block, current turn, AI state) stored as JSON on the call row; no in-memory sessions, so any engine instance can answer any turn, and a restarted worker resumes the call.
- One executor class per block type: `BlockExecutor.execute(block, ctx) → Step` (next, jump, wait, end) plus commands. Instant blocks (Condition, Go to, Transfer to script, Opt out, App action that doesn't wait, Send and continue) run inside the same turn and continue.
- **Never fail the caller**: exception → system "sorry" + goodbye; log + alert; never an empty turn.
- **Call trail**: every block visited, key, speech, match, request result and AI stage stored in `call_events` → per-block stats ("60% pressed 1", "25% said it").
- Guards: 25 block visits per call without caller input, max call length, max wait, voicemail length, 5 script transfers.

---

## 12. Cost per block

<table id="bkmrk-block-cost-any-text-"><thead><tr><th>Block</th><th>Cost</th></tr></thead><tbody><tr><td>Any text message</td><td>TTS once per distinct text (cached); nothing per call after that</td></tr><tr><td>Say/Play, Menu, Collect, Poll, Pick, Voicemail, Goodbye</td><td>Call minutes + SIP trunk on AT</td></tr><tr><td>Speech in an Ask block</td><td>+ transcription of the answer (seconds); smart matching adds a small model call</td></tr><tr><td>Ask question</td><td>+ transcription of the answer</td></tr><tr><td>Connect (waiting and talking)</td><td>Minutes + trunk</td></tr><tr><td>Send request, Condition, Go to, Transfer to script, Opt out</td><td>Free</td></tr><tr><td>App action</td><td>Whatever the client app charges for it (SMS, payment fee); nothing on the engine</td></tr><tr><td>AI Agent</td><td>Minutes + trunk + OpenAI Realtime usage</td></tr></tbody></table>