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:


Contents

Page What's in it
Part 1 — How Ringmaster works Read first: the objects, a call step by step, call-outs, flows and versions, agents, webhooks, app actions, recordings, costs, formats
Part 2A — Setup: basics, accounts, numbers ping, script schema and check · accounts · numbers
Part 2B — Call flows, media, knowledge scripts and versions · audio files · knowledge base for the AI
Part 2C — Calls, call-outs, OTP, reports call history, timeline, recording, transcript · quick calls and call-outs · OTP · stats and exports
Part 2D — Agents and live calls departments, agents, the browser desk · accept, hold, transfer, hang up
Part 2E — App actions and opt-outs registering the client app's actions and answering them · the do-not-call list
Part 3 — What the engine sends you webhooks: envelope, signature, retries, every event · agent desk signals
Part 4 — Reference every status and value · every error code · limits and defaults · glossary
Part 5 — Call flow reference the flow document · messages · variables · all 18 blocks with every setting and exit · Waiting Room · AI Agent · check codes and fixes
Part 6 — Building the screens flow editor · calls page and call log · reports · agent desk and queue · which webhook updates what · the don't list
Appendix — Not for client apps engine-internal routes · what the Ringmaster owner sets up with ringmaster-admin

Standard Response Format

Every response uses the Globe envelope.

Success

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

Error

{
  "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"] }
}
Field Type Description
success boolean true for success, false for errors
httpStatus string HTTP status, e.g. "200 OK", "409 CONFLICT"
message string Human-readable result; safe to show
code string Errors only: stable machine code to switch on (NOT_FOUND, VALIDATION_FAILED, SCRIPT_IN_USE…)
action_time string Server time of the response (local, no zone)
data any The payload; on errors, details (field messages, what is in the way) or null
HTTP code When
400 BAD_REQUEST Body is not valid JSON, a query parameter is missing or not the right type, a value is not allowed
401 UNAUTHORIZED Missing or wrong API key
403 CLIENT_SUSPENDED, FEATURE_OFF, CAN_CALL_REFUSED The app is suspended, the feature is not enabled for it, or its can-call check said no
404 NOT_FOUND No such object, or it belongs to another client app
409 a specific code A rule says no (in use, wrong state, already answered); data says what is in the way
422 VALIDATION_FAILED Field errors in data ({"field": "message"}), or a script's error list
429 RATE_LIMITED More requests per second than the app's limit
500 INTERNAL_ERROR A bug on our side; the message is generic

HTTP Method Badge Standards


Every endpoint at a glance

Group Method Path Page
Basics GET /v1/ping 2A
GET /v1/scripts/schema 2A
POST /v1/scripts/validate 2A
Accounts POST · GET /v1/accounts 2A
GET · PATCH /v1/accounts/{accountId} 2A
Numbers GET · POST /v1/numbers 2A
DELETE /v1/numbers/{numberId} 2A
POST /v1/numbers/{numberId}/assign · /release · /transfer 2A
PUT /v1/numbers/{numberId}/script · /ring-mode 2A
Scripts GET /v1/accounts/{accountId}/scripts 2B
PUT · GET · DELETE /v1/accounts/{accountId}/scripts/{ref} 2B
POST /v1/accounts/{accountId}/scripts/{ref}/versions 2B
GET /v1/accounts/{accountId}/scripts/{ref}/versions/{version} 2B
POST …/versions/{version}/runnable · …/versions/{version}/revoke 2B
GET …/versions/{version}/stats 2B
Media POST · GET /v1/accounts/{accountId}/media 2B
GET · DELETE /v1/media/{mediaId} 2B
Knowledge POST · GET /v1/accounts/{accountId}/kb-docs 2B
DELETE /v1/accounts/{accountId}/kb-docs/{documentId} 2B
Calls GET /v1/calls · /v1/calls/{callId} · /timeline · /recording · /transcript 2C
GET /v1/calls/export.csv 2C
POST /v1/calls (quick call) · /v1/calls/agent 2C
Call-outs POST · GET /v1/callouts 2C
GET /v1/callouts/{id} · /recipients · /stats · /export.csv 2C
POST /v1/callouts/{id}/recipients · /v1/callouts/{id}/{action} 2C
OTP POST /v1/otp 2C
Stats GET /v1/accounts/{accountId}/stats 2C
Agents GET /v1/accounts/{accountId}/agents · /agents/stats 2D
PUT · DELETE /v1/accounts/{accountId}/agents/{ref} 2D
PUT · POST · POST …/agents/{ref}/status · /ready · /lobby-token 2D
Departments POST · GET /v1/accounts/{accountId}/departments 2D
PUT · DELETE …/departments/{departmentId} 2D
PUT · GET …/departments/{departmentId}/members · /queue 2D
Live calls POST /v1/calls/{callId}/accept · /decline · /hold · /unhold · /transfer · /hangup 2D
App actions PUT · GET /v1/actions 2E
POST /v1/calls/{callId}/action-result 2E
Opt-outs POST · GET /v1/accounts/{accountId}/opt-outs 2E
DELETE /v1/accounts/{accountId}/opt-outs/{e164} 2E

Part 1 — How Ringmaster works

← Contents · Next: Part 2A — Setup

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

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

Item What it is Where it's used
API key vk_live_…, shown once Every request: Authorization: Bearer <key>
Webhook URL The client app's HTTPS endpoint, e.g. https://app.example.co.tz/hooks/ringmaster The engine POSTs events there (Part 3)
Webhook secret Shown once; signs every webhook The client app checks each event's signature
Features What the app may do See below
Rate limit Requests per second for the app (fixed one-second window) Over it → 429 RATE_LIMITED
Feature Allows
INBOUND Answering incoming calls. Without it every incoming call is refused (REJECTED, FEATURE_OFF)
CALLOUTS Calling people: quick calls and call-outs (403 FEATURE_OFF without it); callers booking a callback (refused without it)
OTP Voice OTP calls (POST /v1/otp); without it → 403 FEATURE_OFF
AI AI Agent blocks and "smart" (AI) speech matching in Menu, Poll and Pick; a flow using them is refused (403 FEATURE_OFF)
NUMBERS Adding and removing numbers through the API; without it → 403 FEATURE_OFF

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.

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

Limit Default What happens
maxCallMinutes 15 The call is ended (MAX_CALL_LENGTH)
maxWaitSeconds 300 Longest wait in a waiting room, whatever the flow says
callerCallsPer10Min 5 The same caller calling more often is refused (REJECTED, CALLER_LIMIT)
otpPerDestinationPer10Min 3 More OTP calls to one number → refused
voicemailMaxSeconds 120 Longest voicemail
maxBlockVisits 25 Blocks run without any input from the caller before the call is ended (LOOP_GUARD) — stops endless loops

1.5 Calling people (call-outs)

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

Way Endpoint Use
Quick call POST /v1/calls "Your order is ready" to 1–1000 people, now. Created and started in one request
Broadcast POST /v1/callouts kind: BROADCAST The same, but built in steps (create → add people → start), optionally at startsAt
Campaign POST /v1/callouts kind: CAMPAIGN 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
Scheduled callback made by a caller during a call (Schedule call block or AI schedule_call) "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
OTP POST /v1/otp Read a code to a number

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):

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)

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.


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": { … } }

All events and their data: Part 3.


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

Recorded When
Voicemail Always, when a flow reaches a Voicemail block (up to voicemailMaxSeconds)
Live parts 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
Callers' spoken answers Short clips used for speech recognition; not kept as recordings

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.

"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

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

Part 2 — Setups, flows, calls, agents,


Part 2 — Setups, flows, calls, agents,

Part 2A — Setup: basics, accounts, numbers

← Contents · Next: Part 2A — Setup

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

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

Item What it is Where it's used
API key vk_live_…, shown once Every request: Authorization: Bearer <key>
Webhook URL The client app's HTTPS endpoint, e.g. https://app.example.co.tz/hooks/ringmaster The engine POSTs events there (Part 3)
Webhook secret Shown once; signs every webhook The client app checks each event's signature
Features What the app may do See below
Rate limit Requests per second for the app (fixed one-second window) Over it → 429 RATE_LIMITED
Feature Allows
INBOUND Answering incoming calls. Without it every incoming call is refused (REJECTED, FEATURE_OFF)
CALLOUTS Calling people: quick calls and call-outs (403 FEATURE_OFF without it); callers booking a callback (refused without it)
OTP Voice OTP calls (POST /v1/otp); without it → 403 FEATURE_OFF
AI AI Agent blocks and "smart" (AI) speech matching in Menu, Poll and Pick; a flow using them is refused (403 FEATURE_OFF)
NUMBERS Adding and removing numbers through the API; without it → 403 FEATURE_OFF

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.

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

Limit Default What happens
maxCallMinutes 15 The call is ended (MAX_CALL_LENGTH)
maxWaitSeconds 300 Longest wait in a waiting room, whatever the flow says
callerCallsPer10Min 5 The same caller calling more often is refused (REJECTED, CALLER_LIMIT)
otpPerDestinationPer10Min 3 More OTP calls to one number → refused
voicemailMaxSeconds 120 Longest voicemail
maxBlockVisits 25 Blocks run without any input from the caller before the call is ended (LOOP_GUARD) — stops endless loops

1.5 Calling people (call-outs)

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

Way Endpoint Use
Quick call POST /v1/calls "Your order is ready" to 1–1000 people, now. Created and started in one request
Broadcast POST /v1/callouts kind: BROADCAST The same, but built in steps (create → add people → start), optionally at startsAt
Campaign POST /v1/callouts kind: CAMPAIGN 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
Scheduled callback made by a caller during a call (Schedule call block or AI schedule_call) "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
OTP POST /v1/otp Read a code to a number

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):

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)

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.


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": { … } }

All events and their data: Part 3.


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

Recorded When
Voicemail Always, when a flow reaches a Voicemail block (up to voicemailMaxSeconds)
Live parts 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
Callers' spoken answers Short clips used for speech recognition; not kept as recordings

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.

"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

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

Part 2B — Call flows, media, knowledge base

← Contents · Previous: Part 2A — Setup · Next: Part 2C — Calls

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. Life of a flow: Part 1 §1.6.

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: GET {base_url}/accounts/{accountId}/scripts

Success Response: data = array of flows, sorted by ref, each as in D3.


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: PUT {base_url}/accounts/{accountId}/scripts/{ref}

Request JSON Sample:

{ "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:

{
  "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: GET {base_url}/accounts/{accountId}/scripts/{ref}

Success Response JSON Sample:

{
  "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)
warnings Only in the answer to a push (D5); 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) brings it back.

Endpoint: DELETE {base_url}/accounts/{accountId}/scripts/{ref}

Success Response JSON Sample:

{
  "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):

{
  "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)
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): errors → nothing stored. Fixed sentences are turned into audio straight away, and an AI Agent's knowledge is prepared in the background.

Endpoint: POST {base_url}/accounts/{accountId}/scripts/{ref}/versions

Request JSON Sample:

{
  "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):

{
  "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:

{
  "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" }
  ]
}
{
  "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 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.


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: GET {base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}

Success Response JSON Sample:

{
  "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
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: POST {base_url}/accounts/{accountId}/scripts/{ref}/versions/{version}/runnable

Success Response: 200, message "Version is runnable", data = the version (runnable: true).

Errors:

{
  "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: POST {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: GET {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:

{
  "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: POST {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)
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. Use the id in a flow only once it is READY.

Errors:

{
  "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: GET {base_url}/accounts/{accountId}/media

Success Response JSON Sample:

{
  "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: GET {base_url}/media/{mediaId}

Success Response: one item as in E2. Errors: 404 "Audio not found".


E4. Delete audio

Endpoint: DELETE {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). 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: POST {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:

{
  "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: GET {base_url}/accounts/{accountId}/kb-docs

Success Response: data = array of documents as in F1.

F3. Delete a document

Endpoint: DELETE {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 2 — Setups, flows, calls, agents,

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

← Contents · Previous: Part 2B — Call flows · Next: Part 2D — Agents

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).

G1. List calls

Endpoint: GET {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)
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 summary):

{
  "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: GET {base_url}/calls/{callId}

Success Response JSON Sample (a caller left a voicemail while waiting for an agent):

{
  "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); 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)
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: GET {base_url}/calls/{callId}/timeline

Success Response JSON Sample (first events):

{
  "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)
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: GET {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: GET {base_url}/calls/{callId}/transcript

Success Response JSON Sample:

{
  "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: GET {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: POST {base_url}/calls/agent

Request JSON Sample:

{
  "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):

{
  "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) · 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. 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: POST {base_url}/calls

Request JSON Sample:

{
  "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), already RUNNING (or SCHEDULED in quiet hours: it waits).

Errors:


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: POST {base_url}/callouts

Request JSON Sample (campaign):

{
  "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.


H3. Add people

Purpose: Add people to a DRAFT call-out (call it several times for long lists).

Endpoint: POST {base_url}/callouts/{id}/recipients

Request JSON Sample:

{
  "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:

{
  "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: POST {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). It never resumes them by itself.


H5. List call-outs

Endpoint: GET {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: GET {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:

{
  "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)
answeredSeconds / keys / outcomes From the answered call

H7. Get a call-out

Endpoint: GET {base_url}/callouts/{id}

Success Response JSON Sample (a callback a caller booked for "baada ya dakika tano"):

{
  "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: GET {base_url}/callouts/{id}/stats

Success Response JSON Sample:

{
  "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: GET {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: POST {base_url}/otp

Access Level: 🔒 Protected · Bearer API key · feature OTP

Request JSON Sample:

{
  "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):

{
  "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:

{
  "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: GET {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):

{
  "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}:

[ { "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.

Part 2 — Setups, flows, calls, agents,

Part 2D — Agents and live calls

← Contents · Previous: Part 2C — Calls · Next: Part 2E — App actions

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; the messages the desk receives: Part 3.

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: POST {base_url}/accounts/{accountId}/departments

Request JSON Sample:

{
  "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"):

{
  "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:

{
  "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: GET {base_url}/accounts/{accountId}/departments

Success Response: data = array of departments (I1 shape), by name.

I3. Update a department

Endpoint: PUT {base_url}/accounts/{accountId}/departments/{departmentId}

Request: same as I1; 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: DELETE {base_url}/accounts/{accountId}/departments/{departmentId}

Success Response: 200, message "Department deleted", data null. Its members are removed from it.

Errors:

{
  "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: PUT {base_url}/accounts/{accountId}/departments/{departmentId}/members

Request JSON Sample:

{ "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: GET {base_url}/accounts/{accountId}/departments/{departmentId}/queue

Success Response JSON Sample:

{
  "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: PUT {base_url}/accounts/{accountId}/agents/{ref}

Request JSON Sample:

{ "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):

{
  "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)
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: GET {base_url}/accounts/{accountId}/agents

Success Response: data = array of agents (I7 shape), by display name. Use it for a wallboard together with agent.status webhooks.

I9. Remove an agent

Endpoint: DELETE {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: PUT {base_url}/accounts/{accountId}/agents/{ref}/status

Request JSON Sample:

{ "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:

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: POST {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: POST {base_url}/accounts/{accountId}/agents/{ref}/lobby-token

Success Response JSON Sample:

{
  "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",
    "token": "eyJhbGciOiJIUzI1NiIsInR5…"
  }
}

In the browser (LiveKit JS SDK):

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.


I13. Agent stats

Endpoint: GET {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:

{
  "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": 2, "declined": 0, "talkSeconds": 50.0 } }
  ]
}
values Description
offered Rings
answered Accepted
missed Rang until the ring time ran out
declined Pressed Decline
talkSeconds Time talking to callers

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: POST {base_url}/calls/{callId}/accept

Request JSON Sample:

{ "agentRef": "asha" }

Success Response JSON Sample:

{
  "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:


J2. Decline a ringing call

Endpoint: POST {base_url}/calls/{callId}/decline

Request: {"agentRef": "asha"}. Success Response: 200, message "Call declined". The engine rings the next agent; the decline counts as a missed ring (I1 maxMissedBeforeAway). Errors: 409 NOT_RINGING.


J3. Hold

Purpose: The caller hears hold music; the agent's microphone is muted for the caller.

Endpoint: POST {base_url}/calls/{callId}/hold

Request: {"agentRef": "asha"}. Success Response: 200, message "On hold". Holding twice is harmless.

Errors:

{
  "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: POST {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: POST {base_url}/calls/{callId}/transfer

Request JSON Samples:

{ "agentRef": "asha", "toDepartmentId": "dbd644af-e460-40fa-9b09-4cb838915bdc" }
{ "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: POST {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.

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.

Part 2 — Setups, flows, calls, agents,

Part 2E — App actions and opt-outs

← Contents · Previous: Part 2D — Agents · Next: Part 3 — What the engine sends you

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.

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: PUT {base_url}/actions

Request JSON Sample:

[
  { "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).

Errors:

{
  "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"
  ]
}
{
  "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}\"" }
}
{
  "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: GET {base_url}/actions

Success Response JSON Sample:

{
  "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: POST {base_url}/calls/{callId}/action-result

What the client app received (webhook, Part 3):

{ "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:

{
  "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:

{
  "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:

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: POST {base_url}/accounts/{accountId}/opt-outs

Request JSON Sample:

{ "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: GET {base_url}/accounts/{accountId}/opt-outs

Success Response JSON Sample:

{
  "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: DELETE {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 3 — What the engine sends you

← Contents · Previous: Part 2E — App actions · Next: Part 4 — Reference

The engine talks to the client app in three ways:

Channel Direction Used for
Webhooks engine → client app's webhook URL (POST) Everything that happened: calls, blocks, recordings, call-outs, agents, media, app actions
The can-call check engine → client app's can-call URL (GET), waits for the answer Ask before a call: is this account allowed to call now? (optional)
Agent desk signals engine → the agent's browser, through LiveKit Ring, ring cancelled, connecting

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


3.1 Webhooks

Every webhook

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": { … }
}
Part Description
X-Ringmaster-Event Same as type
X-Ringmaster-Delivery Same as id; unique per event, also on retries — drop repeats by this id
X-Ringmaster-Timestamp Unix seconds when this attempt was sent
X-Ringmaster-Signature Lower-case hex HMAC-SHA256 of "<timestamp>.<raw body>" with the webhook secret
createdAt When the event happened (attempts later keep it)
data 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

Checking the signature

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

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"])
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

Events

Type When Main data
call.started A call begins (incoming picked up, or dialling out) callId, type, direction, from, to
call.answered The person answered a call-out / OTP / agent call same as call.started
call.block A caller's choice or answer worth acting on (menu, collect, poll, pick, ask, request, app action) callId, block, type, …
call.ended The call is over the full call summary
call.missed A caller gave up waiting, or nobody answered them callId, from, to, result
call.scheduled A caller booked a callback callId, calloutId, at, to, said
voicemail.received A voicemail is stored callId, recordingId, durationSec, from
recording.ready A recorded live part (agent talk) is stored same
summary.ready Transcript and summary of a recording are ready callId, summary, sentiment, language, fields, unanswered
ai.unanswered The AI could not answer some caller questions callId, questions
action.requested A flow asks the client app to do one of its actions actionId, name, callId, params, wait, timeoutAt
request.failed A Send request delivery kept failing for 24 h callId, block, attempts, status, error
callout.progress Every 30 s while a call-out runs counts
callout.paused The engine paused a call-out counts + reason
callout.completed Everybody was called (or it was stopped) counts
callout.recipient_results Batches of people who are done results[]
agent.status An agent's status or presence changed agentRef, status, online, departments
media.ready / media.failed An uploaded audio file finished processing mediaId, durationMs / error
test.ping Sent by the owner to test the setup —

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


call.started

{ "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

{ "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:

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

{ "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.

{ "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.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.

{ "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." } }
Field Description
at Booked time, with the account's offset
scriptRef The Outgoing flow that will play
said The caller's words (absent for a fixed time)
calloutId Show or cancel it with the call-out API (Part 2C)

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

{ "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

{ "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.

{ "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).

{ "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" } } } }
Field Description
params Filled in: templates replaced by the call's values ({caller} → the number)
wait / timeoutAt true: the caller is waiting; answer before timeoutAt or the flow takes its timeout exit
meta.client The call's meta (from the call-out person, OTP request…)

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:

{ "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.

{ "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" } } } }
reason (paused) Why
ACCOUNT_SUSPENDED / CLIENT_SUSPENDED The account or the client app was suspended
VERSION_REVOKED Its flow version was stopped
FEATURE_OFF The owner took the client app's CALLOUTS feature away
CAN_CALL:<reason> The can-call check said no (3.2)
PAUSED_BY_CLIENT / your own text You paused it (POST …/pause); the webhook is sent for these too

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.

{ "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.

{ "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

{ "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:

{ "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.


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):

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)
Query Description
accountRef The account
type INCOMING · BROADCAST · CAMPAIGN · AGENT_CALL
from / to E.164 (from is empty for call-outs)

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

{ "allowed": true, "maxMinutes": 10, "reason": null }
Field Description
allowed false refuses the call (missing = true)
maxMinutes Optional: end this call after this many minutes (e.g. the business's remaining credit)
reason Your short code, kept with the refusal

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

Call type When the check fails When allowed: false
INCOMING Allowed (a caller is never refused because the client app is down) Refused: caller hears the service is unavailable; result: REJECTED
BROADCAST / CAMPAIGN Refused The call-out is paused (CAN_CALL:<reason>, callout.paused)
AGENT_CALL Refused 403 CAN_CALL_REFUSED to POST /v1/calls/agent

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).

type When Fields What the desk does
ring A caller is offered to this agent callId, from, department Show "Incoming call", play the ring, offer Accept / Decline
ring_cancel The offer ended callId, reason Stop ringing, hide the card
connecting The agent's mobile answered the ring (ring mode with mobiles) callId, via: "mobile" Show "On a call on your mobile"
ring_cancel.reason Means
answered_elsewhere Another agent accepted it
caller_left The caller hung up or left the waiting room
timeout Nobody pressed Accept within the department's ring time
declined This agent declined
not_joined The agent accepted but their browser never joined the call room (15 s); the caller went back to the queue

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

{ "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.

Part 4 — Reference

← Contents · Previous: Part 3 — What the engine sends you · Next: Part 5 — Call flow reference

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


Calls

type

Value Call
INCOMING Someone called one of the account's numbers
BROADCAST A quick call or broadcast call-out (also scheduled callbacks)
CAMPAIGN A campaign call-out
OTP A code read out (POST /v1/otp)
AGENT_CALL An agent called someone from the browser (POST /v1/calls/agent)
TEST A test call

direction

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

status (live state)

Value Means
QUEUED Waiting for a free line (call-out pacing, concurrency)
DIALING Dialling out
RINGING The person's phone is ringing
IN_SCRIPT Blocks are running
WAITING In a waiting room (for an agent, a reply, an app action)
CONNECTING An agent accepted; joining the call
LIVE Talking with an agent
ENDED Over

result (how it went)

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

endReason

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

endedBy

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

handledBy

SCRIPT · AGENT · AI · VOICEMAIL.


Call-outs

Field Values
kind BROADCAST · CAMPAIGN
status DRAFT · SCHEDULED · RUNNING · PAUSED · COMPLETED · STOPPED · CANCELLED (last three are final)
pauseReason PAUSED_BY_CLIENT (or your text) · ACCOUNT_SUSPENDED · CLIENT_SUSPENDED · VERSION_REVOKED · FEATURE_OFF · CAN_CALL:<reason>
Recipient status PENDING · DIALING · DONE · SKIPPED
Recipient result A call result (above), or OPTED_OUT
Skipped reason (adding people) INVALID_NUMBER · NOT_TANZANIAN · DUPLICATE · OPTED_OUT

Agents and departments

Field Values
Agent status OFFLINE · AVAILABLE · RINGING · BUSY · WRAP_UP · AWAY
Department strategy RING_ALL · ROUND_ROBIN
Department ifNobodyOnline WAIT · EXIT_NO_ANSWER
Number ringMode BROWSER · MOBILE · BROWSER_AND_MOBILE
Desk signal type ring · ring_cancel · connecting
ring_cancel.reason answered_elsewhere · caller_left · timeout · declined · not_joined

Numbers, accounts, flows, media

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

Timeline events

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

Area Events
Start and end CALL_RECEIVED (incoming) · DIALING · RINGING · ANSWERED · NOT_ANSWERED (with sipStatus, reason) · DIAL_FAILED · LIMIT (refused: kind) · SCRIPT_END · CALLER_LEFT · ENDED (result, endReason, endedBy)
Flow 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)
Caller input 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
Schedule call SCHEDULE_HEARD (said, at) · SCHEDULE_CHANGED · SCHEDULE_REFUSED (reason) · CALL_SCHEDULED (at, script, said)
Waiting room WAIT_START (reason) · WAIT_UPDATE · WAIT_OPTION (key, exit) · WAIT_END (seconds, gaveUp)
Agents 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
Recording RECORDING_NOTICE · RECORDING_STARTED · VOICEMAIL (recorded) · RECORDING_READY (kind, seconds) · RECORDING_FAILED · TRANSCRIPT_READY (language, sentiment)
Integrations REQUEST · REQUEST_SENT · REQUEST_QUEUED · REQUEST_CALLBACK · REPLY_NOT_JSON · ACTION_REQUESTED (name, actionId) · ACTION_RESULT (name, outcome, late)
AI Agent 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
OTP OTP_SENT (the code masked: ••••) · OTP_READ
Voice worker WORKER_JOINED · WORKER_LEFT · WORKER_ERROR · WORKER_USAGE

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


Error codes

The code field of an error response.

General

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

By area (409 unless noted)

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

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.

Area Codes
Structure DIRECTION_MISSING · DIRECTION_MISMATCH · START_MISSING · START_UNKNOWN · TOO_MANY_BLOCKS · UNKNOWN_BLOCK_TYPE
Exits EXIT_TARGET_MISSING (points to no block) · ⚠ EXIT_UNCONNECTED (call ends there) · ⚠ EXIT_UNKNOWN (not an exit of that block) · GOTO_TARGET · RETRIES
Messages MESSAGE_MISSING · MESSAGE_SOURCE · MESSAGE_TEXT · MESSAGE_TOO_LONG · MEDIA · MEDIA_MISSING · UNKNOWN_VARIABLE · VARS_IN_INCOMING
Ask blocks 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
Connect, waiting room DEPARTMENT · WAIT_GIVE_UP · WAIT_OPTION · ⚠ WAIT_OPTIONS_UNANNOUNCED
Send request REQUEST_URL · REQUEST_HTTPS · REQUEST_METHOD · REQUEST_BODY · REQUEST_AUTH · REQUEST_MODE · REQUEST_TIMEOUT · CALLBACK_URL
App action ACTION (unknown action or a parameter missing)
Logic CONDITION_TEST · TRANSFER_TARGET · RETURN_EXIT · RETURN_OUTSIDE_SHARED
Schedule call SCHEDULE_TARGET (no runnable Outgoing flow by that ref and version) · SCHEDULE_TIME (exactly one of after, at, ask) · SCHEDULE_TRIES
AI Agent AGENT_START · AGENT_MOVE · AGENT_TOOL · AGENT_FIELD · AGENT_KB · AI_LANGUAGE
Features FEATURE_OFF (only from POST /v1/scripts/validate: the block uses AI and the client app has no AI feature)

⚠ = warning.


Limits and defaults

What Value
Requests per second per client app, set by the owner (429 above it)
Blocks per flow 60 · menu keys 10 · retries 3 · text 500 characters
Account limits defaults maxCallMinutes 15 · maxWaitSeconds 300 · callerCallsPer10Min 5 · otpPerDestinationPer10Min 3 · voicemailMaxSeconds 120 · maxBlockVisits 25
Account defaults 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)
Quick call 1–1000 people · maxTries 1–3 (default 1) · retryGapSec 300–86400 (default 300)
Call-out maxTries 1–5 (default 1) · retryGapSec ≥ 300 (default 3600) · pacePerMin 1–600 (default 60) · ≤ 10,000 people per add
Scheduled callback 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
Department ring time 10–60 s (20) · wrap-up 0–300 s (0) · missed rings before Away 0–20 (5; 0 = never)
Agent presence grace 30 s · join after accept 15 s · lobby / call tokens 10 min
App actions ≤ 50 · timeoutSec 10–1800 (default 120)
Audio upload ≤ 10 MB · ≤ 5 minutes · 500 MB per account
Knowledge document .txt .md .pdf · ≤ 10 MB · text cut at 200,000 characters
OTP code 4–10 digits
Stats / exports ≤ 400 days per request · default last 7 days · CSV ≤ 100,000 rows
Webhook retries 1 min, 5 min, 30 min, 2 h, 6 h, 12 h … until 24 h
Can-call check connect 0.7 s, answer 1.5 s
Pages calls: size default 50 · recipients: default 100, max 500
Numbers that can be called Tanzanian (+255) only

Glossary

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

Part 5 — Call flow reference

← Contents · Previous: Part 4 — Reference · Next: Part 6 — Building the screens

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. How to build the editor screen is in Part 6 §6.1.

Two endpoints keep an editor honest:

Conventions in this page:


5.1 The flow document

{
  "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.")

Field Type Required Description
direction string Yes 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
start object Yes The first block: {"call": id} for INCOMING, {"out": id} for OUTGOING, {"in": id} for SHARED
blocks object Yes Block id → block. At most 60 blocks
vars array No 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)
inputs string[] No SHARED only: what a Transfer to script block must pass in; used inside as {in.<name>}
settings.language string No 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
settings.voice string or object No The voice for every text: "marin", or one per language {"sw": "marin", "en": "cedar"}
settings.speed number or object No 0.7–1.5 (rounded to 0.05), or one per language. Default 1.0
settings.record string No "off": no live recording for calls on this flow, even if the account records. Absent: the account decides

Block ids

Every block's shape

"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


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.

{ "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}.")

Field Description
src 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
text Up to 500 characters. May contain {variables} (§5.3)
media The id of an audio file of this account (2B, E1). It must be READY when the version is pushed
lang sw or en: the language of this text. Absent: the call's current language
voice, speed This message's voice and speed. Absent: the block's, then the flow's settings, then the engine's default

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

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

Made by blocks

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

How they're read aloud:


5.4 Settings any block can have

Field Description
before 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")
countAs 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
voice, speed The block's voice and speed, for its texts that don't set their own

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

Field Default Description
timeoutSec 8 (Collect 10) Seconds of silence after the prompt before it counts as no input
retries 2 How many times the block asks again after no input, a wrong key or words it didn't understand. 0–3
laterWords — Extra words meaning "not now" for the later exit (below)

5.5 The blocks

Group Blocks
Speak Say
Ask Menu · Collect number ⚠ · Poll · Pick from list · Ask question
Connect Connect to department · Voicemail · Schedule call · AI Agent ⚠
Integrate Send request ⚠ · App action (⚠ when the action is risky)
Logic Condition · Go to · Transfer to script · Return
End Opt out · Goodbye

say

Says a text or plays audio, then goes on.

Setting Type Default Rules
msg message req

Exits: next. Timeline: SAY / PLAY.

"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.

Setting Type Default Rules
msg message req Say the keys and words: "Bonyeza moja au sema mauzo…" ("Press one or say sales…")
options array req 1–10 options (below)
speech object absent = keys only {"match": "words" | "smart", "minConfidence": 0.5, "language": "sw", "maxSec": 12}
timeoutSec · retries · laterWords 8 · 2 §5.4

Each option:

Field Description
k The key: 0–9, * or #, unique in the menu. May be empty only when speech is on (a voice-only option)
id The exit name. Default: the key. Letters, digits, _; unique in the menu
label 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.")
words 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)
lang sw or en: picking this option also switches the call's language (a language menu)

speech.match:

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>}.

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

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>}.

Setting Type Default Rules
msg message req Say what each key means
answers int 5 1–5: keys 1..answers are valid
saveAs string the block id
labels array — [{"k": "1", "label": "Mbaya", "words": ["mbaya"]}, …]: names for reports, and words for speech
speech bool or object off true, or {"match": "words" | "smart", "minConfidence"}. A spoken number ("tano", "five") always works
timeoutSec · retries · laterWords 8 · 2

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.*}.

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

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.

Setting Type Default Rules
msg message req
saveAs string req Variable name
maxSec int 20 5–60: the longest answer
language string the call's sw / en: the language to listen for
useMenuWords bool false If the Menu just before was answered by voice in 6 or more words, those words are the answer and the question is skipped
retries int 2 Asked again after silence

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). The caller waits in the Waiting Room until an agent accepts.

Setting Type Default Rules
department string req A department id of this account (not deleted)
record bool account false: don't record the talk with the agent, even if the account records
wr object engine defaults The Waiting Room (§5.6): music, updates, queue position, option keys, give-up time

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), 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.

Setting Type Default Rules
msg message req "Acha ujumbe baada ya mlio." ("Leave a message after the beep.")
maxSec int 60 5–120, and never above the account's voicemail limit
beep bool true 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
warnBeforeEndSec int 10 (a third of a short maxSec) 0 = no warning
thanks message the system goodbye Said after the message

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.

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

Exactly one of after, at, ask.

Rules the engine applies, whatever the flow says:

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.

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.

Setting Type Default Rules
url string req https:// with a public host. {variables} in it are URL-encoded. Private, loopback and cloud-metadata addresses are refused
method string POST GET · POST · PUT · PATCH · DELETE
params object — Query parameters {name: template}
headers object — {name: template}. X-Voice-*, Host and Content-* are set by the engine
body object {"type": "json"} type: json · form · raw · none; value: the body, with variables
auth object {"type": "signature"} signature (signed with the account's request secret) · bearer + token · basic + username + password · apikey + header + value · none
map object — Reply JSON path → variable: {"data.balance": "balance", "items[0].name": "first"}
timeoutSec int 10 1–15
mode string reply 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}
wr object — The Waiting Room for reply / callback when the answer takes more than about a second

What the engine sends and expects:

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): send an SMS, take a payment, create a ticket. The engine has no built-in SMS or payment.

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

Exits:

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.

test.kind Other fields Exits
hours — open · closed (the account's opening hours, in its time zone)
calledRecently days (default 7) yes · no: an outgoing call from this account reached this caller in the last days
variable var, op, value yes · no
contactExists — yes · no. Reserved: nothing fills it yet, so it is always no. Don't offer it in the editor

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

op Yes when
equals (default) · notEquals the value is / isn't value
gt · lt greater / less than value (as numbers; 45,000 works)
in one of a comma list: "value": "M-Pesa, Tigo Pesa"
matches matches a regular expression
exists · empty has a value / has none (no value needed)

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

Timeline: CONDITION (test, result).


goto

Jumps to another block.

Setting Type Default Rules
target string req The block to jump to (to is accepted too)
maxLoops int 0 = no limit After this many jumps from this block in one call → tooMany

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.

Setting Type Default Rules
script · version string · int req A SHARED flow and a runnable version (pinned: changing the shared flow changes nothing here until the pin moves)
inputs object — {name: template}: every input the shared flow declares

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.

Setting Type Rules
exit string req. [a-zA-Z][a-zA-Z0-9_]{0,30}

Exits: none.


optout

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

Setting Type Default
msg message "Umeondolewa kwenye orodha. Hutapigiwa tena." / "You have been removed from the list. We will not call again."

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

goodbye

Ends the call.

Setting Type Default
msg message "Asante kwa kupiga simu. Kwaheri." / "Thank you for calling. Goodbye."

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.

"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.")

Field Default Description
start — Played once when waiting starts
music hold_default An audio id of the account, looped
updates — Messages played in turn, one every updateEverySec
updateEverySec 30
queuePosition false Connect only: "Wewe ni wa 3 kwenye foleni." ("You are number 3 in the queue.") with each update
options — [{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
optionsAfterSec 0 Keys work only after this many seconds
giveUpSec 300 10–1800, and never above the account's longest wait. Then noAnswer (Connect), timeout (Send request, App action)

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

"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.")

Field Default Description
goal "help the caller" What the conversation is for
persona · guard — Who the AI is; what it must never do
voice · speed · model engine's The realtime voice and model
maxMin 5 1–30 minutes, then the time exit
language the call's {"mode": "FIXED", "languages": ["sw"]} or {"mode": "AUTO", "default": "sw"} (follows the caller; sets {lang})
kb — Knowledge: items (Q/A), docs (ids from 2B, F1), 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
fields — [{name, desc}]: details to collect; each becomes {ai.<name>}
tools — [{type, name, …}]. name: lower case, digits, _. Types: request + req (a Send request block of this flow, reused) · app action + action + params · transfer + department
canSchedule — {script, version}: lets the AI book a later call (same rules as Schedule call)
start req The first stage
anytime — Leave-when rows checked in every stage
stages req Stage id → stage

Stage:

Field Description
type greet · understand · answer (from knowledge) · collect · act · confirm · handover · custom
say Said on entering the stage
instruction Extra instruction for this stage
collect collect stages: field names (declared in fields)
actions act stages: tool names
leave [{kind, to}]. to: a stage id, or exit:<goal|human|vm|cb|nohelp|time|err>
ho 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

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.


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.

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

Besides the issues, the check returns:

Part 6 — Building the screens

← Contents · Previous: Part 5 — Call flow reference · Next: Appendix

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, and the live screens. 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

Don't


6.1 The flow editor

Load

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

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:

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

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

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)

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:

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): it shows every block, sentence, key and match.

Don't


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.

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

Result labels

result Label Colour
HANDLED_BY_SCRIPT Served by the flow green
HANDLED_BY_AGENT Talked to an agent green
HANDLED_BY_AI Served by AI green
VOICEMAIL Left a message blue
MISSED Nobody answered red
ABANDONED Hung up while waiting red
HUNG_UP_EARLY Hung up at once grey
REJECTED Refused (limits, suspended) grey
ANSWERED (outgoing) Answered green
NO_ANSWER · BUSY · DECLINED Not answered · Busy · Declined amber
UNREACHABLE · INVALID_NUMBER · FAILED · NOT_DIALED Couldn't reach · Wrong number · Failed · Not called grey

Call detail

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

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

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".

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

Don't


6.3 Reports

Account dashboard

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

Tile / chart Formula
Calls incoming + outgoing
Didn't get a person missed + abandoned: callers who waited for an agent and gave up or weren't answered; as a share of incoming
Left a message voicemail
Served without a person (byScript + byAi) / (byScript + byAgent + byAi + voicemail)
Hung up at once hungUpEarly / incoming (a long or unclear greeting)
Average call length seconds / answered (answered counts incoming calls and answered outgoing ones)
Busiest hours groupBy=hour: a heat map by hour
Per line groupBy=number
By type groupBy=type

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

Flow performance

Call-outs

Screen part Endpoint
List and status GET /v1/callouts?accountId=… (H5)
Header: status, counts, pause reason GET /v1/callouts/{id} (H7)
Progress bar, by result, by try, by hour GET /v1/callouts/{id}/stats (H8)
People and their result GET /v1/callouts/{id}/recipients?status=… (H6)
Download GET /v1/callouts/{id}/export.csv (H9)

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. Answer rate = answered / offered. Live status comes from agent.status.

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


6.4 Live screens

The agent desk

Everything is in Part 2D and the desk signals in Part 3. 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).

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


6.5 Which webhook updates which screen

Webhook Update
call.started / call.answered Live calls list; queue wall
call.block The caller's answers as they happen (a menu choice, a number, a poll answer); act on them in your app (open a ticket)
call.ended Calls list, call detail, your own call store (reports, search)
call.missed "Missed calls" inbox; notify the business
call.scheduled Scheduled callbacks list
voicemail.received · recording.ready Voicemail inbox; the call's player
summary.ready Transcript and summary on the call; voicemail preview
ai.unanswered "Questions the AI couldn't answer": suggestions for the knowledge base
action.requested Not a screen: do the action and answer (2E)
request.failed An alert for the business's integration
callout.progress · callout.paused · callout.completed · callout.recipient_results Call-out page; your own record of who was reached
agent.status Agent list, desk, queue wall
media.ready · media.failed Audio library (processing → ready)

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.

Appendix — Not for client apps

Appendix — Not for client apps

← Contents · Previous: Part 6 — Building the screens

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.

Route Who calls it What for
POST /internal/calls/inbound · POST /internal/calls/{callId}/turn The voice worker Asks the engine "what next?" on each step of a call (worker token)
GET /internal/audio/tts/{hash} · GET /internal/audio/media/{id} · POST /internal/tts/claim · POST /internal/tts/{hash} The voice worker Audio for calls; turning texts into speech
POST /hooks/livekit LiveKit Room and participant events (signed by LiveKit)
POST /hooks/resume/{token} A business's own system 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)
GET /media/{mediaId}/{hash}.mp3?t=… A browser The signed previewUrl of an audio file (Part 2B); no key, expires
/sim/* Developers, local only The phone simulator; never enabled next to a real trunk
/actuator/* Monitoring Health and metrics

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

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

API keys

Command Does
key rotate <app> A new key (shown once); the old one keeps working until revoked, so both can be used during the switch
key revoke <app> <prefix> Stops a key at once
key list <app> Keys by prefix, created, last used and from which IP

Numbers

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

Audit

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


C. Versioning of this API

Scripts & Blocks Reference

Client app developers: read 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).

Script direction Where it's used Start block exits
Incoming Numbers page (one script per number) call · Incoming call
Outgoing Call-outs (broadcast, campaign, API POST /calls) out · Person answered
Shared Only through Transfer to script, from either direction in · Transferred in

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

Command Used by
play Say, announcements, AI disclosure, recording notice, Goodbye, system messages
ask Menu, Collect number, Poll, Pick from list, Ask question (keys and/or speech)
record Voicemail
wait Waiting Room: Connect, Send request (wait), App action (wait)
connect / hold Connect once an agent accepted; hold and transfer during the live call
ai AI Agent
hangup Goodbye, Opt out (after its message), limits

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


2. Messages

Same shape everywhere:

{ "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}".)


3. Variables and metadata

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

Group Examples Available
System {caller}, {number}, {callId}, {date}, {time}, {key} (last key) Everywhere
Contact / call-out {name}, {amount}, {order}… (declared by the script; filled per person from the client app, a file, or the API) Outgoing scripts
Collected {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 After that block
Reply {status}, {ref}… mapped from a Send request reply; {action.<block>.*} from an App action result After that block
AI {ai.patient_name}, {ai.date}, {ai.amount}, {ai.language} After the AI Agent
Live facts {business.open}, {business.opens_at}, {agents.online}, {queue.wait} Everywhere (read live)

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

{
  "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

Block Settings Exits Command
Say / Play message (one or several parts), interruptible (default off) next play

Ask: keys, speech, or both

All Ask blocks share one rule set:

Block Settings Exits Notes
Menu prompt, options [{k key, id, label, words [spoken words], lang}], speech {match: words | smart, minConfidence}, timeoutSec, retries each option id, noInput, notUnderstood 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).
Collect number ⚠ question, save as {input.x}, min/max digits, finish key #, pattern, speech (off by default) ok, failed Keys by default: spoken account numbers are where speech recognition errs most. With speech, spoken numbers ("sifuri saba moja…", "zero seven one…") become digits.
Poll question, answers 1–5 with optional words, save as next, noInput
Pick from list intro, list source (a Send request reply items[], or inline), label template, per page (≤ 9, 0 = more) chosen (sets {choice.id}, {choice.label}, {choice.*}), empty, noInput With speech, the caller can say the item's name.
Ask question (new) question, save as {answer.x}, maxSec (5–60, default 20), language answered, silence "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.

Connect

Block Settings Exits Command
Connect to department department, record On/Off, Waiting Room. Whether members' mobiles ring too is the number's ringMode (D51), not a block setting noAnswer + Waiting Room option exits wait, then connect
Voicemail prompt, max length (30/60/120 s), beep, finish key — (ends) play + record
Schedule call (new, SCHEDULE-CALL.md) 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 scheduled, notUnderstood, declined, refused books a one-person call-out; says the time back in Kiswahili time
AI Agent ⚠ see §8 goal, human, vm, cb, nohelp, time, err ai

Integrate

Block Settings Exits
Send request ⚠ method, URL, params, headers, body (JSON / form / raw / none), auth (Signature default, Bearer, Basic, API key, none), reply mapping, timeout, mode Send and continue: next · Wait for reply / Wait for callback: success, failure, timeout (+ Waiting Room options)
App action action (a name the client app registered), params, message before waiting, Waiting Room (if the action waits) Doesn't wait: next · Waits: each registered outcome + failed + timeout (+ Waiting Room options)

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:

Logic

Block Settings Exits
Condition 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, >, <, in, matches, exists, empty). Later: agents online, queue length, caller in a list, percentage split Business hours: open, closed · Answered by: person, machine · others: yes, no
Go to target block, maxLoops (default 3) — (jumps; after maxLoops jumps from this block in one call → its tooMany exit, or the call ends)
Transfer to script (new) script ref + pinned version, inputs {name: template} the target's return exits (e.g. done, failed)

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

End

Block Settings Exits Notes
Opt out confirmation message next Adds {caller} to the account's do-not-call list
Return exit (name) — Shared scripts only: back to the Transfer block, through its exit of that name
Goodbye message — Ends the call

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

6. Announcement (any block)

Automatic, not editable per block:


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.

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

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

8. AI

8.0 AI language

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

"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)

8.2 AI Agent

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

{
  "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.")

Part Meaning
Stage types Greet, Understand, Answer from knowledge, Collect details, Take action, Confirm, Handover, Custom
Stage settings instruction, says on entering, fields to collect, allowed actions, may answer from knowledge, leave when rows
Leave-when kinds Then (always), Asks a question, Wants to book/act, All info collected, Action succeeded, Action failed, Asks for a person, Caller upset
Anytime rules Checked every turn from any stage (asks for a person, caller upset)
Knowledge base Inside the agent: facts/FAQs, documents, website page. Used only in stages that allow it. Never invent
Fields Become {ai.*} variables for later blocks
Tools 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
Live facts {business.open}, {business.opens_at}, {agents.online}, {queue.wait} from the system
Handover Checks live facts: open + free → human · open + busy → human (queue) · no agents → call me back · closed → leave a message
Exits goal, human, vm (leave a message), cb (call me back), nohelp, time, err: ordinary exits that may lead to any block

How it runs:

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)

Block Settings
say msg (message or list), interruptible
menu msg, options [{k, id (default = k), label, words, lang}], speech {match: words | smart, minConfidence, language} or absent, timeoutSec (8), retries (2)
collect msg, saveAs → {input.<saveAs>}, minDigits/maxDigits (1–20), finishKey (#), pattern (regex), sensitive, retries, timeoutSec, speech (bool, off; never with sensitive)
poll msg, answers (1–5), saveAs → {poll.<saveAs>} (default block id), labels [{k, label, words}], speech
pick 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
ask 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)
connect department, record, wr
schedule 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
Ask blocks' later exit 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
voicemail 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
request 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
app_action action, params {name: template}, msg (spoken before waiting), wr
condition test: hours | recent | contact | answeredBy | var {var, op, value}
goto to, maxLoops
transfer script (ref), version, inputs {name: template}
return exit
optout, goodbye msg

Behaviour:


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 & approval


11. Execution (engine)


12. Cost per block

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