Ringmaster Engine API Documentation
- Introduction
- Part 1 — How Ringmaster works
- Part 2 — Setups, flows, calls, agents,
- Part 2A — Setup: basics, accounts, numbers
- Part 2B — Call flows, media, knowledge base
- Part 2C — Calls, call-outs, OTP, reports
- Part 2D — Agents and live calls
- Part 2E — App actions and opt-outs
- Part 3 — What the engine sends you
- Part 4 — Reference
- Part 5 — Call flow reference
- Part 6 — Building the screens
- Appendix — Not for client apps
- Scripts & Blocks Reference
Introduction
Base URL: https://<ringmaster-host>/v1 (local: http://localhost:8085/v1)
Short Description: Ringmaster is an engine for intelligent call centres. Services build their calling features on it (they are its client apps): the engine does the telephony, the call logic, the AI and the agents; the service owns its users, its screens and its business rules. A business on such a service (an account) gets phone numbers; each number plays a call flow (a script of blocks: greet, menu, collect a number, voicemail, AI agent, connect to a person…). Ringmaster also calls people (reminders, campaigns, callbacks, OTP codes), rings the business's agents in their browser, records and transcribes calls, and tells the client app what happened through signed webhooks. This book is for developers of a client app: every endpoint, every event the engine sends, every value.
Hints:
- Every
/v1request carries the client app's API key:Authorization: Bearer <key>. Keep it on your server; never ship it to a browser or phone. - Ringmaster knows nothing about the client app's users, contacts, money or messages. It stores what it needs to ring and play: account settings, numbers, call flows, agents (a ref and a name), calls and their reports.
- Phone numbers are always E.164 (
+255745051250). Times in responses are UTC ISO 8601 (2026-11-13T14:41:00Z); times inside an account (opening hours, quiet hours, "call me at 9") are in the account's time zone (defaultAfrica/Dar_es_Salaam). - Refusals you should handle in code come with a stable
code(SCRIPT_IN_USE,VERSION_NOT_RUNNABLE…); switch on the code, show the message. Full list in Part 4. - How a call flow is written (all blocks, their settings and exits) is in Part 5; how to build the editor, calls, reports and live screens on top of the API is in Part 6.
- A working sample client to try everything by hand: the demo console (
console/,python3 console/serve.py); its "API calls" bar shows the exact request for each click.
Contents
| 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
- GET - GET - read only
- POST - POST - create / act
- PUT - PUT - create or replace
- PATCH - PATCH - change some fields
- DELETE - DELETE - remove
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):
- Quiet hours (account
quietHours, default 21:00–07:00): nobody is called inside them; calls wait for the end. - Opt-outs: a number on the account's opt-out list is skipped (
SKIPPED), checked again just before dialling. - Retries: busy, no answer, unreachable or failed → tried again after
retryGapSec(≥ 300 s) up tomaxTries(quick call ≤ 3, campaign ≤ 5). Declined (the person rejected the call) is never retried. - Pace: campaigns dial at most
pacePerMin.
Call-out statuses: DRAFT → SCHEDULED → RUNNING ⇄ PAUSED → COMPLETED / STOPPED / CANCELLED.
Recipient: PENDING → DIALING → DONE (with a result) or SKIPPED.
1.6 A call flow's life
PUT …/scripts/{ref} create the flow (name, INCOMING or OUTGOING); direction never changes
POST …/scripts/{ref}/versions push version 1, 2, 3 … — each version is frozen once stored
│ the engine checks it (Part 5 §5.9): errors → 422 with the list, nothing stored
│ warnings → stored, warnings returned
▼
runnable? ── no (you pushed runnable:false: it has risky blocks and waits for approval)
│ POST …/versions/{n}/runnable after the client app's approval step
▼ yes
PUT /v1/numbers/{id}/script put the version on a number (Incoming) — or —
POST /v1/calls · /v1/callouts use it for calls out (Outgoing)
POST …/versions/{n}/revoke stop a version for good: a number still playing it refuses calls,
call-outs using it pause (VERSION_REVOKED) — move them first
DELETE …/scripts/{ref} delete the flow (refused while anything uses it; archived if it ever ran)
- Versions never change. To change a flow, push the next version and move the number to it. Calls keep pointing at the exact version they ran, so reports stay true.
- Pushing the same version number with the same document again is safe:
200 "Version already stored". - Approval is the client app's policy. Some blocks are risky:
collect(callers' digits),request(calls a URL),ai_agent, and App actions the client app registered as risky (e.g. pay). The engine lists them inriskyBlockswhen a version is checked or stored, and trusts therunnableflag it is given: push such a version withrunnable: false, let the business's reviewer approve it in the client app, thenPOST …/versions/{n}/runnable. A not-runnable version can't be put on a number. A call-out may already use it: it is saved and waits (waitingApproval: true), then starts by itself once the version is approved. testOnlyversions are for test calls only and never become runnable.- Reading a flow back:
GET …/scripts/{ref}/versions/{n}returns the full document. Secrets stored in Send request blocks come back as"(hidden)". - Layout (block positions on the client app's canvas) belongs to the client app: the engine drops a
layoutkey on push. - Texts are turned into speech when the version is stored, so the first caller doesn't wait.
1.7 Agents in the browser
Client app (once): POST departments {name, strategy, ringTimeoutSec…} · PUT agents/{ref} {displayName}
PUT departments/{id}/members {agentRefs: [...]} (list order = round-robin order)
Agent's browser:
"Start shift" → client app backend → POST agents/{ref}/lobby-token → {url, room, token}
→ browser joins the lobby room (LiveKit JS SDK, data only, no mic)
→ client app backend → PUT agents/{ref}/status {AVAILABLE}
A caller reaches a Connect block → hears "tunakuunganisha…", then music
→ engine sends {type:"ring", callId, from, department} into the lobby
"Accept" → client app backend → POST /v1/calls/{callId}/accept {agentRef} → {url, room, token}
→ browser joins the call room with the microphone on → they talk
"Hold" / "Transfer" / "Hang up" → POST /v1/calls/{callId}/hold · /transfer · /hangup {agentRef}
Call ends → agent is WRAP_UP for the department's wrapUpSec, then AVAILABLE (or POST …/ready)
Agent statuses: OFFLINE → AVAILABLE → RINGING → BUSY → WRAP_UP → AVAILABLE; AWAY = on a break
(online, no calls). One call at a time per agent. Missing maxMissedBeforeAway rings in a row (default 5) moves the
agent to AWAY. Every change is sent as an agent.status webhook (for a wallboard).
Ring modes (per number, PUT /v1/numbers/{id}/ring-mode): BROWSER (agents' browsers), MOBILE (agents'
mobiles), BROWSER_AND_MOBILE.
The browser needs HTTPS (or localhost) for the microphone, and the "Start shift" click is what allows the ring
sound. Details of every signal: Part 3.
1.8 Webhooks
Every event is a signed POST to the client app's webhook URL:
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: 5b0c7f8e-… (same as the body's id; use it to drop repeats)
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 9f2c… hex HMAC-SHA256(secret, "<timestamp>.<raw body>")
{ "id": "5b0c7f8e-…", "type": "call.ended", "createdAt": "2026-11-13T16:44:04Z", "data": { … } }
- Answer 2xx quickly (do the work after). Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then every 12 h until 24 h have passed; then the event is marked failed.
- Events are written in the same database step as the change they describe, so none is lost; a repeat can arrive —
drop it by
id. - Check the signature on the raw body before parsing, and refuse timestamps more than 5 minutes old.
All events and their data: Part 3.
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 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):
- Quiet hours (account
quietHours, default 21:00–07:00): nobody is called inside them; calls wait for the end. - Opt-outs: a number on the account's opt-out list is skipped (
SKIPPED), checked again just before dialling. - Retries: busy, no answer, unreachable or failed → tried again after
retryGapSec(≥ 300 s) up tomaxTries(quick call ≤ 3, campaign ≤ 5). Declined (the person rejected the call) is never retried. - Pace: campaigns dial at most
pacePerMin.
Call-out statuses: DRAFT → SCHEDULED → RUNNING ⇄ PAUSED → COMPLETED / STOPPED / CANCELLED.
Recipient: PENDING → DIALING → DONE (with a result) or SKIPPED.
1.6 A call flow's life
PUT …/scripts/{ref} create the flow (name, INCOMING or OUTGOING); direction never changes
POST …/scripts/{ref}/versions push version 1, 2, 3 … — each version is frozen once stored
│ the engine checks it (Part 5 §5.9): errors → 422 with the list, nothing stored
│ warnings → stored, warnings returned
▼
runnable? ── no (you pushed runnable:false: it has risky blocks and waits for approval)
│ POST …/versions/{n}/runnable after the client app's approval step
▼ yes
PUT /v1/numbers/{id}/script put the version on a number (Incoming) — or —
POST /v1/calls · /v1/callouts use it for calls out (Outgoing)
POST …/versions/{n}/revoke stop a version for good: a number still playing it refuses calls,
call-outs using it pause (VERSION_REVOKED) — move them first
DELETE …/scripts/{ref} delete the flow (refused while anything uses it; archived if it ever ran)
- Versions never change. To change a flow, push the next version and move the number to it. Calls keep pointing at the exact version they ran, so reports stay true.
- Pushing the same version number with the same document again is safe:
200 "Version already stored". - Approval is the client app's policy. Some blocks are risky:
collect(callers' digits),request(calls a URL),ai_agent, and App actions the client app registered as risky (e.g. pay). The engine lists them inriskyBlockswhen a version is checked or stored, and trusts therunnableflag it is given: push such a version withrunnable: false, let the business's reviewer approve it in the client app, thenPOST …/versions/{n}/runnable. A not-runnable version can't be put on a number. A call-out may already use it: it is saved and waits (waitingApproval: true), then starts by itself once the version is approved. testOnlyversions are for test calls only and never become runnable.- Reading a flow back:
GET …/scripts/{ref}/versions/{n}returns the full document. Secrets stored in Send request blocks come back as"(hidden)". - Layout (block positions on the client app's canvas) belongs to the client app: the engine drops a
layoutkey on push. - Texts are turned into speech when the version is stored, so the first caller doesn't wait.
1.7 Agents in the browser
Client app (once): POST departments {name, strategy, ringTimeoutSec…} · PUT agents/{ref} {displayName}
PUT departments/{id}/members {agentRefs: [...]} (list order = round-robin order)
Agent's browser:
"Start shift" → client app backend → POST agents/{ref}/lobby-token → {url, room, token}
→ browser joins the lobby room (LiveKit JS SDK, data only, no mic)
→ client app backend → PUT agents/{ref}/status {AVAILABLE}
A caller reaches a Connect block → hears "tunakuunganisha…", then music
→ engine sends {type:"ring", callId, from, department} into the lobby
"Accept" → client app backend → POST /v1/calls/{callId}/accept {agentRef} → {url, room, token}
→ browser joins the call room with the microphone on → they talk
"Hold" / "Transfer" / "Hang up" → POST /v1/calls/{callId}/hold · /transfer · /hangup {agentRef}
Call ends → agent is WRAP_UP for the department's wrapUpSec, then AVAILABLE (or POST …/ready)
Agent statuses: OFFLINE → AVAILABLE → RINGING → BUSY → WRAP_UP → AVAILABLE; AWAY = on a break
(online, no calls). One call at a time per agent. Missing maxMissedBeforeAway rings in a row (default 5) moves the
agent to AWAY. Every change is sent as an agent.status webhook (for a wallboard).
Ring modes (per number, PUT /v1/numbers/{id}/ring-mode): BROWSER (agents' browsers), MOBILE (agents'
mobiles), BROWSER_AND_MOBILE.
The browser needs HTTPS (or localhost) for the microphone, and the "Start shift" click is what allows the ring
sound. Details of every signal: Part 3.
1.8 Webhooks
Every event is a signed POST to the client app's webhook URL:
POST https://app.example.co.tz/hooks/ringmaster
Content-Type: application/json
X-Ringmaster-Event: call.ended
X-Ringmaster-Delivery: 5b0c7f8e-… (same as the body's id; use it to drop repeats)
X-Ringmaster-Timestamp: 1794588244
X-Ringmaster-Signature: 9f2c… hex HMAC-SHA256(secret, "<timestamp>.<raw body>")
{ "id": "5b0c7f8e-…", "type": "call.ended", "createdAt": "2026-11-13T16:44:04Z", "data": { … } }
- Answer 2xx quickly (do the work after). Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then every 12 h until 24 h have passed; then the event is marked failed.
- Events are written in the same database step as the change they describe, so none is lost; a repeat can arrive —
drop it by
id. - Check the signature on the raw body before parsing, and refuse timestamps more than 5 minutes old.
All events and their data: Part 3.
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 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 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:
403 FEATURE_OFF"Call-outs are not enabled for this client app" (noCALLOUTSfeature)400"Call-outs run Outgoing scripts only" · "Script version belongs to another account"409 VERSION_REVOKED422 VALIDATION_FAILED"Some people have no value for a script variable",data: [{"e164": "+2557…", "missing": "product"}]422 VALIDATION_FAILED"Nobody can be called",data= the skipped list (H3)
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 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:
400"Status can be set to AVAILABLE, AWAY or OFFLINE"409 AGENT_BUSY"Finish the current call first"
Being online (connected to the lobby) is separate from the status: an agent whose browser is gone for more than 30 s (and who is not on a call) is set
OFFLINEby 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:
409 NOT_RINGING"This call is not ringing for you any more" (ring time ran out, the caller left, or it was cancelled)409 ALREADY_TAKEN"Another agent already answered this call"404"Call not found" · "Agent not found: …"
J2. Decline a ringing call
Endpoint: 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 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:
400"outcome is required" · "outcome must be one of [failed, paid, failed_pin, cancelled]"404"Call not found" · "Action not found on this call"409 ACTION_ALREADY_RESOLVED"This action already has the result paid"409 ACTION_TIMED_OUT—timeoutSecpassed; the call already took thetimeoutexit
If a flow names an action the client app no longer has, the block takes its
failedexit and the timeline showsERROR {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
- Answer 2xx within a few seconds, then do the work (queue it). Any other answer, or no answer, is retried: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then every 12 h, until 24 h after the event; after that the event is marked failed and not sent again.
- Events are stored in the same database step as the change they report, so none is lost when the engine restarts.
- Order is not guaranteed across retries:
call.endedcan arrive before a latecall.block. UsecreatedAt, and treat each event as "the latest I know", not as a step in a strict sequence. - The same event can arrive twice (a 2xx that got lost on the way back). Keep the
ids you processed. - The owner can send a
test.ping(ringmaster-admin client-app test-webhook <app>) to check the URL and the secret.
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:
GET /v1/scripts/schema(2A, A2): block types, groups, exits and which need approval, generated from the engine's code. Build the block library from it.POST /v1/scripts/validate(2A, A3): the same checks as a push. Run it on every save and show the issues on the blocks (§5.9).
Conventions in this page:
- Default is what the engine uses when the field is absent. Leave a field out rather than sending the default.
- A setting marked req must be present, or the check reports an error.
- ⚠ next to a block = a risky block: a version with it should be approved before it runs (Part 1 §1.6).
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
- Any string that is unique in the flow (
menu,pay_1,b7). The id is what exits point to, what the timeline names, and what per-block stats count by (2B, D9). - Keep an id when the block is edited, so stats stay comparable across versions. Make a new id only for a new block.
Every block's shape
"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
xmaps each exit name to the id of the next block.- An exit that is missing or empty ends the call with the system goodbye ("Asante kwa kupiga simu. Kwaheri." /
"Thank you for calling. Goodbye."). The check warns
EXIT_UNCONNECTEDfor each one, and the timeline recordsEXIT_UNCONNECTEDwhen a call ends there. A warning is not an error: sometimes ending there is what the author wants. - Loops are allowed. An exit may point back to an earlier block or to the block itself ("press 0 to repeat",
back to the menu). A loop is stopped only when it goes 25 blocks in a row without the caller pressing a key,
speaking or recording (
LOOP_GUARD), so a caller going round a menu is never cut off. - Exit names a block doesn't have get the warning
EXIT_UNKNOWN; they're ignored. - Not warned when unwired:
later(optional), and exits the block can't take as set up (notUnderstoodon a keys-only menu; Schedule'snotUnderstoodwithoutask,declinedwithout confirmation).
5.2 Messages
Every place a flow speaks (msg, before, ask, wr.start, wr.updates, thanks) takes a message: one
part, or a list of parts played one after another.
{ "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:
moneyvariables are read with thousands separators: 45000 → "45,000".- Numbers in
{input.*}and{poll.*}are read digit by digit, in words. - Sensitive values (Collect with
sensitive) are usable in the call but shown as••••in the timeline, webhooks and exports.
5.4 Settings any block can have
| 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 |
| 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) |
- Keys always work. A Menu, Poll or Pick also listens for speech when it has a
speechsetting; Collect whenspeech: true; Ask question always listens. - The caller can press a key or start talking during the prompt: the prompt stops (barge-in).
- After all retries, the block leaves through its give-up exit (
noInput,notUnderstood,failedorsilence). laterexit (Menu, Collect, Poll, Pick): when it is wired, words like "baadaye", "niko busy", "sina muda", "later", "call me back" take it instead of a retry. Wire it to a Schedule call block. When it isn't wired, those words are treated like any other answer.
5.5 The blocks
| 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:
words: the caller's words are compared with each option'swords(exact or close).smart: words first; if none match, the AI picks the option the caller meant from labels and words ("nataka kuongea na mtu wa malipo", "I want to talk to someone about payments"). BelowminConfidence→ not understood. Needs the client app'sAIfeature. Without it, the push is refused; if AI is taken away later, calls fall back towords.
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:
- 5 minutes to 30 days ahead. A time too soon is moved up to the earliest allowed minute.
- Quiet hours move the time, and the read-back says so.
- A new booking for the same caller and flow replaces the old one; at most 3 waiting per caller.
- Times are understood by the engine's rules first ("kesho saa tatu asubuhi", "baada ya saa moja", "tomorrow at 9").
Other wordings ("tarehe 30") use the AI, only when the client app has
AI.
Exits: scheduled · notUnderstood (the time wasn't understood, after retries) · declined (the caller said no
to the time, after retries) · refused (the engine said no; SCHEDULE_REFUSED.reason: TIME_OUT_OF_RANGE, OPTED_OUT, TOO_MANY_PENDING,
SCRIPT_NOT_RUNNABLE, FEATURE_OFF, NO_TIME).
Timeline: SCHEDULE_HEARD (said, at) · SCHEDULE_CHANGED · CALL_SCHEDULED (at, script) · SCHEDULE_REFUSED (reason).
Webhook: call.scheduled {callId, calloutId, at, to, scriptRef, said}.
ai_agent
⚠ Risky. A spoken conversation with an AI, in stages, with its own knowledge, fields and tools. Needs the client app's AI
feature. Its seven exits lead to any block. Full settings: §5.7.
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:
- Signed requests carry
X-Voice-Timestamp,X-Voice-Signature(hex HMAC-SHA256 oftimestamp.body),X-Voice-Idempotency-KeyandX-Voice-Call-Id. JSON bodies get ametaobject (callId,callOutId,block). - 2xx =
success. Any other status, a redirect (never followed), a reply over 64 KB, a blocked address or a network error =failure. No reply in time =timeout. - A top-level
sayin the reply becomes{reply.say}. A top-levelitemsarray feeds Pick from list. callbackmode: the body must contain{callback_url}(checkCALLBACK_URL). The business POSTs the result there beforewr.giveUpSec."result"or"status"set tofailure,failed,error,declinedorcancelled→failure; anything else →success.- Secrets (
auth.token,auth.password,auth.value) are encrypted when the version is pushed and come back as"(hidden)"from D6. Push"(hidden)"back unchanged to keep the stored secret of the same block id in the latest version; send a new value to replace it. A"(hidden)"on a block that had no secret is refused (REQUEST_AUTH).
Exits: continue mode: next. Other modes: success · failure · timeout · each wr.options[].exit.
Timeline: REQUEST_SENT · REQUEST (status, latencyMs, outcome, saved) · REQUEST_QUEUED · REQUEST_CALLBACK · REPLY_NOT_JSON · WAIT_*.
Webhook: request.failed (a continue request that failed for 24 h); call.block with late: true when a callback arrives after the caller left.
app_action
Asks the client app to do one of the actions it registered (Part 2E): 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:
- Action that doesn't wait:
next. A bad parameter or unknown action →failed. - Action that waits: each registered outcome (
paid,failed_pin…) ·failed·timeout(the action'stimeoutSec, default 120) · eachwr.options[].exit.
Variables: {action.<block>.outcome} and each key of the result's data as {action.<block>.<key>}.
Timeline: ACTION_REQUESTED · ACTION_RESULT (outcome, late) · ERROR (ACTION_UNKNOWN, ACTION_PARAMS).
The block is ⚠ when the action is registered as risky.
condition
Branches on a test. Instant: the caller hears nothing.
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
| 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
| 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.
- AI-voice notice: a call that will play generated voice starts with one line, "Habari, unazungumza na huduma ya kidijitali ya {business}." ("Hello, you're speaking with {business}'s digital service."), worded per account. Skipped when the call plays only uploaded audio.
- Recording notice: once per call, before the first recorded part, when the account records.
- "Sorry" and goodbye: if something breaks during a call, the caller hears "Samahani, kuna tatizo kwa sasa…"
("Sorry, there's a problem right now…") and the call ends (
SYSTEM_ERROR). The caller never hears silence. - Retry lines: "Chaguo hilo si sahihi." ("That option is not valid.") for a wrong key; "Samahani, sijaelewa. Unaweza kusema: …" ("Sorry, I did not understand. You can say: …") with the option labels; "Namba uliyoingiza si sahihi." ("The number you entered is not valid.") in Collect; "Samahani, sikusikia vizuri. Tafadhali sema tena." ("Sorry, I did not hear that well. Please say it again.") in Ask question.
5.9 Check codes
POST /v1/scripts/validate and a push return errors and warnings, each {block, code, message}. block is
the block id, or null for the whole flow. An error stops the push; a warning doesn't. Show each on its block; the
message is written to be shown as it is.
| 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:
texts: the fixed texts that will be made into audio;vars: the declared call-out values (for the call-out upload screen);riskyBlocks: the blocks that need approval (2B, D7).
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
- Call the engine only from the client app's backend. The browser talks to the client app, never to the engine.
- Map your business to an engine account once (
externalRef= your business id) and keep theaccountId. - Store what webhooks tell you: calls (
call.ended), voicemails, summaries, call-out results. Your own reports and search then run on your own database, and the engine stays the source for detail (timeline, recording). - On start-up, call
GET /v1/ping: it returns the client app'sfeatures. Show only the screens the app may use (no Call-outs screen withoutCALLOUTS, no AI blocks withoutAI). - Switch on the error
code, show themessage(README). Messages are written for people.
Don't
- Don't put the API key, the webhook secret or a lobby token for someone else in a page, a mobile app or a log.
- Don't poll the engine to find out that something happened. Webhooks tell you (§6.5).
- Don't fail on fields, enum values, timeline events or webhook types you don't know. The engine adds them within
/v1. - Don't show engine words to business staff when yours are clearer: "script" is "call flow" in this book;
MISSEDcan be "Nobody answered".
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
- Versions never change. "Edit" means: load the latest version into a draft kept by the client app, change it, and push it as a new version. Keep the draft in your database so a closed tab loses nothing.
- Forms per block follow Part 5: every setting there, with its default as the placeholder. Leave a field out instead of sending its default.
- Block ids: generate them (
b1,b2… or a slug of the name) and never change an existing block's id. Stats are counted by id. - Settings that point elsewhere use pickers, never free text: departments, audio, other flows, app actions.
- Variables: offer a picker with what the flow can use at that block (Part 5 §5.3).
- Send request secrets show as
(hidden). Leave them as they are to keep them; let the user type a new one to replace. - Without the
AIfeature, hide the AI Agent block and thesmartmatching choice.
Check, save, publish
every change (debounced ~1 s) → POST /v1/scripts/validate {definition} → show issues on the blocks
Save → POST …/scripts/{ref}/versions {version: latest+1, runnable, definition}
Approve (risky versions) → POST …/versions/{v}/runnable
Go live → PUT /v1/numbers/{numberId}/script {scriptVersionId} (incoming)
or pick the version in a call-out (outgoing)
- Show
errorsin red on their block,warningsin amber;block: nullissues at the top (Part 5 §5.9). Disable Save while there are errors. version= the highest existing version + 1. A409 VERSION_EXISTSmeans someone else saved first: reload, show their version, and let the user re-apply their change. A retry of the same push is safe (same document →200, not a new version).- Approval is the client app's rule (2B, D7). A common
rule: versions with
riskyBlocksare pushed withrunnable: falseand a manager approves them; the rest go outrunnable: true. - Saving never changes what callers hear. Only "Go live" does. Say so on the button.
- Roll back = put an older runnable version back on the number. No push needed.
- Stop a version that must not run any more:
POST …/revoke(D8). Numbers playing it refuse calls, so move them first.
Delete
DELETE …/scripts/{ref} (D4). A 409 SCRIPT_IN_USE lists what uses the flow (numbers, callouts, scripts):
show the list with links. A flow that has run calls is archived, not erased: its call history stays readable.
Per-block stats on the canvas
GET …/versions/{v}/stats (D9), for the version shown. Draw on each block:
visits(how many calls came through);- drop-off =
hangups / visits(callers who hung up here: a long or confusing prompt); - confusion =
retries / visitsandnoInput(callers who didn't know what to press); - Send request blocks:
reqFail,reqTimeout,reqMsP50; - Waiting rooms:
gaveUp.
Stats appear a few minutes after calls end, and only for that version: compare versions side by side, not mixed.
Try it
Push the version and put it on a test number, or call it with a quick call to your own phone (POST /v1/calls,
needs CALLOUTS). Then open the call's timeline (§6.2): it shows every block,
sentence, key and match.
Don't
- Don't edit the JSON of a stored version and push it under the same number.
- Don't let users type department ids, audio ids or flow refs.
- Don't push on every keystroke: check on every change, push on Save.
- Don't put a version on a number before the push returned
201(or200for the same document).
6.2 Calls page and call detail
The calls list
GET /v1/calls?accountId=…&page=…&size=50 (G1), newest first. Filters: from / to, type, result, status,
number, calloutId.
| 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 |
- Live calls:
status=LIVE, orstatusnotENDED. Refresh this tab oncall.started/call.ended, not with a timer. - For search over months (by contact, by tag), use your own copy of
call.endedevents.
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 |
Link each block heading to the flow editor at that block, on the version the call ran (script.version from G2).
Don't
- Don't send the recording's URL with the API key to the browser. Stream it through your backend, after checking that the user may hear it.
- Don't show a sensitive value; the engine already masks it as
••••, and so should anything you add. - Don't poll for the transcript: wait for
summary.ready.
6.3 Reports
Account dashboard
GET /v1/accounts/{id}/stats?from=…&to=…&groupBy=day (N1).
| 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
- Funnel per block: D9 on the canvas (§6.1).
- Outcome tags (
countAs, App action outcomes): per call inoutcomes(G2,call.ended), per call-out inbyOutcome(H8). For account-wide numbers by tag, count them in your own copy ofcall.ended, or from the CSV export (outcomescolumn).
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
- Don't compute reports by walking
GET /v1/callspage by page. Use the stats endpoints, the exports, or your own copy ofcall.ended. - Don't mix time zones: stats days and hours are in the account's time zone, timestamps are UTC.
6.4 Live screens
The agent desk
Everything is in Part 2D and the desk signals in Part 3. In short:
- Agent signs in to the client app → backend
PUT …/agents/{ref}(once) andPOST …/agents/{ref}/lobby-token. - The page joins the LiveKit lobby room with that token and listens for
ring,ring_cancel,connecting. - Start shift button: unlocks the browser's sound (a ring needs a user gesture) and sets status
AVAILABLE. - On
ring: show the caller and the flow's notes, ring, flash the title. Accept → backendPOST /v1/calls/{id}/accept→ join the call room with the returned token. Decline →POST …/decline. - In the call: Hold, Transfer (to a department), Hang up, each through the backend.
- 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
- Don't let the browser call
acceptorhangupon the engine directly. The backend checks the agent is who they say. - Don't ring with an
<audio>element created before the user clicked anything: browsers block it.
6.5 Which webhook updates which screen
| 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
- Never put the API key or webhook secret in a browser, app or log.
- Never edit a stored version: push a new one.
- Never change an existing block's id: stats and links are by id.
- Never let users type ids (departments, audio, flows, actions): use pickers.
- Don't poll for what a webhook tells you (calls, recordings, summaries, call-out progress, agent status).
- Don't fail on unknown fields, values, events or webhook types.
- Don't offer what the client app's features don't allow (
GET /v1/ping→features). - Don't put a version live that is waiting for approval; don't approve in the same click as saving a risky version.
- Don't show
••••values unmasked, or engine costs to businesses unless you bill by them. - 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
- Paths start with
/v1. Withinv1, the engine only adds: new endpoints, new optional request fields, new response fields, new enum values, new webhook types and new timeline events. Client apps must ignore fields and values they don't know. - Anything that would break a client app (removing or renaming a field, changing a meaning) comes as
/v2, with time to move. - The script schema has its own
schemaVersion(Part 2A, A2).
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 totarget).
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 inarchive/.
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):
- A turn reply is never empty: at least one command, or the system goodbye.
- An announcement plays before the prompt and can't be interrupted; the prompt can (barge-in).
- A prompt can have several parts (text and audio mixed); the worker plays them in order.
- Each turn has a number; a late or repeated result for an old turn changes nothing (the engine re-sends the current commands).
2. Messages
Same shape everywhere:
{ "src": "text", "text": "Habari {name}", "lang": "sw" }
{ "src": "record", "media": "med_123" }
{ "src": "upload", "media": "med_456" }
{ "src": "library", "media": "sys_hold_default" }
(The Swahili example reads "Hello {name}".)
text→ filled, turned into words where needed (§3), then turned into audio by the engine's TTS and cached: the same text and voice is generated once and reused by every call (ARCHITECTURE §5.2). Max 500 characters.- Voice and speed (who speaks, how fast). The client sets them where they want; the nearest wins:
- on the message:
{ "src": "text", "text": "…", "voice": "cedar", "speed": 1.2 } - on the block:
"voice": "cedar", "speed": 0.9next totype(applies to every message the block says, including its retries and system lines) - in the script:
settings.voice("cedar"or per language{ "sw": "marin", "en": "cedar" }),settings.speed(number or per language) - the engine default (
ringmaster.tts.voice, speed 1.0)
- on the message:
- Speed runs 0.7 (slower) to 1.5 (faster), rounded to steps of 0.05; 1.0 is normal.
- Voices (OpenAI): female-sounding
marin,coral,shimmer,sage; male-soundingcedar,ash,echo,ballad,verse. - The audio cache keys on voice and speed (
cedar@1.2), so changing either makes new audio; normal speed keeps the plain name. - The AI Agent sets its own:
agent.voiceandagent.speed(agent.openaiVoiceis the older name for the voice). record/upload/library→ the worker plays the file; it must be READY.- A prompt is a message or a list of messages:
"msg": [ {…text…}, {…library…} ]. - Missing media at call time → system "sorry" message, then goodbye; alert.
3. Variables and metadata
Variables are typed values that blocks read aloud, send, or test. Written as {name}.
| 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) |
- Incoming calls only know
{caller}(+ the client app's contact if found). An Incoming script must not depend on call-out variables. - Missing value at runtime → empty string + warning in the call trail (validation prevents most cases; the call-out form refuses people without values).
- Variable types (
vars[].type):text(default),money,number,date,digits. Spoken forms are made by the engine for the call's language before TTS:money45000 → "shilingi elfu arobaini na tano" (sw) / "forty-five thousand shillings" (en);digits0712… → read one by one;date→ "tarehe 20 Novemba" (sw). In Send request URLs/bodies and App action params the raw value is used. [VERIFY: whether the TTS voice reads digits well enough to skip the conversion]
Metadata: opaque JSON the client attaches to a call or call-out ({"orderId": 991}). Never spoken, never sent to Send request unless mapped into a variable. Echoed back in every event as meta.client.
4. Script document
{
"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
- Keys always work. A block also listens for speech when it has a
speechsetting. In the Studio this follows Twilio's pattern: connecting the block's "said something" exit turns speech on. - Barge-in: the caller can press a key or start talking during the prompt; the prompt stops.
- Type-ahead: keys pressed during an interruptible Say carry into the next Ask.
- No input after
timeoutSec(counted from the end of the prompt) → retry (up toretries, max 3) →noInput. - Not understood (speech that matches nothing) → "Samahani…" ("Sorry…") retry →
notUnderstood. Only on blocks that listen for speech. - Speech is cut by the worker (VAD) and transcribed by the engine (
gpt-transcribe, the block's language, the options' words as hints). The text is saved as{heard}and in the timeline (SPEECH).
| 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:
- Request: method + URL with variables; header
X-Voice-Signature(HMAC-SHA256 of timestamp + body with the account's signing secret),X-Voice-Timestamp,X-Voice-Idempotency-Key(= callId + block). - Body variables filled per call;
metaobject always added (callId,callOutId,block,meta.client). - Reply: 2xx = success (empty 200 = success, nothing mapped); 4xx/5xx = failure; no answer within timeout (default 10 s, max 15 s while the caller waits) = timeout. JSON paths mapped to variables (
data.status→{status}). - HTTPS only; DNS-resolved private / loopback / link-local / metadata IPs blocked; no redirects; response ≤ 64 KB.
- Send and continue: queued, retried up to 24 h; never delays the caller.
- Wait for callback (slow systems): the body includes
{callback_url}, a one-time signed URL for this call + block. The caller waits in the Waiting Room until the business POSTs the result there (mapped like a reply) or the give-up time passes. If the caller already hung up, the result is still stored and reported (call.block). - Reply conveniences: a top-level
saystring can be spoken as-is ({reply.say}, made into audio at the turn); a top-levelitemsarray feeds Pick from list. Recommended reply contract:EXAMPLES.md§0.1. - Secrets (tokens, passwords) stored encrypted; never shown again in full.
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)
- Outcome tag (optional, block setting "Count as"): when a call reaches the block, the tag is added to the call (
paid,booked,promised,wants_callback…). Used by call-out funnels and conversion. Automatic tags: App action outcomes the client app maps withcountAs(e.g.paid), Opt out →opted_out, AI Agent goal →goal, Answered by machine →machine. - Sensitive (Collect number): the value is usable in the call (e.g. sent to a verify request) but stored and shown as ••••, never in exports or webhooks. OTP codes are always sensitive. A sensitive Collect never listens for speech (no transcript of a PIN).
6. Announcement (any block)
- Optional message that plays once when the block starts ("Mazungumzo haya yanarekodiwa…", "This conversation is being recorded…"; "Tafadhali sikiliza kwa makini", "Please listen carefully").
- Added from block settings (+ Add announcement). Text, record, upload or library.
- Not played again when the block repeats after a wrong key.
- Plays before the prompt and can't be interrupted.
Automatic, not editable per block:
- Recording notice: once per call, right before the first recorded part (account wording).
- AI-voice disclosure (D53): OpenAI's usage policy requires telling callers that a voice is AI-generated, and every text prompt uses OpenAI's voice. So a call that will play generated voice starts with one short line, default "Habari, unazungumza na huduma ya kidijitali ya {business}." ("Hello, you're speaking with {business}'s digital service."), editable per account (
settings.aiDisclosure). Played once, before the first generated prompt; skipped when the call plays only recorded audio. The AI Agent adds nothing extra.
7. Waiting Room (Connect, Send request "wait", App action "wait")
What the caller hears and can do while the block is busy. It runs in the call's room: the worker plays it, the engine decides when it ends.
| 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
- Accept is instant: the caller is already in the room, so an agent who accepts is talking within a second (the Voice API needed up to 15 s).
- The Waiting Room says only what the author writes: announce the option keys in
wr.startorwr.updates(e.g. "Bonyeza 9 kuacha ujumbe", "Press 9 to leave a message"). Options with neither give the warningWAIT_OPTIONS_UNANNOUNCED.
8. AI
8.0 AI language
Each AI Agent, and each Ask block with speech, has a language setting chosen by the customer:
- Fixed: listen and speak only the listed language(s), e.g.
["sw"](Kiswahili only) or["sw","en"]; a caller speaking another language is answered in the first one. - Auto: detect the caller's language from their speech and answer in it, switching if the caller switches.
"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:
- The engine sends
ai {instructions, voice, tools, stage, language}; the worker runs a LiveKit Agents session with the OpenAI Realtime model (gpt-realtime-2.1;gpt-live-1to be tested). Tool calls come to the engine as turn results; the engine runs them and replies with the result. Stage changes update the session's instructions. ARCHITECTURE Appendix A.1. - Tools the model gets:
leave_stage(kind)(the stage's non-automatic kinds + anytime kinds),save_field(name, value)in Collect stages (→{ai.<name>}; all collected →done),search_knowledge(query)in Answer stages, the stage's tools,set_language(code)with AUTO language,handover(),end_call(reason: goal_done | caller_done). A Take-action stage leavessuccessonce all its actions worked,failedat the first failure. - Handover uses live facts of the department behind the
humanexit: closed →closed, nobody online →none, someone free →ok, all busy →busy; each row{say, to}(defaults: ok/busy → human, none → cb, closed → vm). - Limits:
maxMin(1–30, default 5) →time; AI not starting within 20 s or failing →err. After the conversation: transcript (speaker-separated) + summary + collected fields →summary.ready. - Knowledge:
kb.items(Q/A),kb.docs(ids fromPOST /v1/accounts/{id}/kb-docs: .txt, .md, .pdf with text, ≤ 10 MB),kb.site(https page, read once when the version is saved). Built per script version in the background; searched with Postgres full-text (all words first, then any word).
Rule of thumb for tools vs exits: if the AI needs the result to keep talking (check balance, book) → a tool inside the agent; if the AI's part is finished (transfer, pay, goodbye) → an exit to a block.
8a. Block settings (exact JSON)
| 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:
- Signing: when the account has a request signing secret every request carries
X-Voice-Timestamp,X-Voice-Signature= hex HMAC-SHA256(secret, timestamp + "." + body) (same scheme as webhooks),X-Voice-Idempotency-Key={callId}:{block}:{visit},X-Voice-Call-Id. JSON bodies getmeta{callId, callOutId, block, client}; form bodies getmeta.callId,meta.block. - Reply: 2xx =
success; other status, redirect (never followed), > 64 KB, blocked address or network error =failure; no reply in time =timeout.{reply.code}is always set; top-levelsay→{reply.say}; top-levelitemsfeeds Pick from list. - Waiting: a reply within ~1 s is used at once, with no Waiting Room; otherwise the caller goes to the Waiting Room and the engine wakes the worker when the reply arrives.
- Wait for callback: the system posts the result to
{callback_url}(POST /hooks/resume/{token}, one-time, valid until the give-up time)."result"(or"status") = failure / failed / error / declined / cancelled →failureexit, anything else →success; the body is mapped withmap. After the caller left, the result is stored and sent ascall.blockwithlate: true. Answers: 200, 404 unknown, 409 already answered, 410 expired. - Send and continue: queued (stored encrypted), retried on 5xx / 408 / 429 / timeout / network errors at 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h… for up to 24 h; a final failure sends
request.failed. - Secrets:
auth.token,auth.password,auth.valueare encrypted when the version is saved (enc:prefix) and decrypted only to send. - App actions:
CLIENT-APP-ACTIONS.md(event, answer, outcomes, timeouts). - Sensitive Collect: the value is kept encrypted in the call state, readable by later blocks (
{input.pin}), shown as••••in keys, timeline, webhooks and any spoken-text log.
9. Validation (on save / before publish)
Loops are allowed: any exit may point back to an earlier block or to the same block ("press 0 to repeat", story → back to the menu). The loop guard ends the call (LOOP_GUARD) only after maxBlockVisits (25) blocks in a row without the caller pressing a key, speaking or recording, so a broken say → goto → say loop stops, while a caller going round a menu is never cut off. Call length is capped separately (maxCallMinutes).
- Start exits exist for the direction; every exit points to an existing block or is intentionally empty.
- ≤ 60 blocks; menus ≤ 10 options; retries ≤ 3; text ≤ 500 characters.
- 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. - 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). - Send request: HTTPS URL, valid JSON body, auth complete.
- Speech: a Menu, Poll or Pick with
speechandmatch: wordsneedswordson every option (warningOPTION_WITHOUT_WORDS); a sensitive Collect can't listen for speech. - 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 inSHAREDscripts. - AI Agent: start stage set, every leave-when connected or intentionally empty, at least one exit reachable.
- Approval flag computed: any ⚠ block → version status
Waiting approval; elseReady. - Text to audio: every fixed text is listed in the version's
textsso its audio is made at publish (ARCHITECTURE §5.2).
10. Versioning & approval
- Draft (editable) → Publish → immutable version
vNwith statusReadyorWaiting approval→Approved/Rejected (reason). - Numbers, call-outs and Transfer to script pin a version. Editing creates
vN+1; running call-outs keepvN. - A call-out on a version waiting approval is saved and starts automatically when approved.
- Rollback = point the number to an older approved/ready version.
- Approval lives in the client app; the engine only stores versions the app pushes as runnable (plus drafts for test calls to verified test numbers).
11. Execution (engine)
- Every result from the worker is one turn:
POST /internal/calls/{callId}/turn(VOICE-WORKER §3). The engine locks the call, applies the result to the block that asked, runs blocks until one needs the caller, and answers with commands. - Call context (variables, retries, visits, current block, current turn, AI state) stored as JSON on the call row; no in-memory sessions, so any engine instance can answer any turn, and a restarted worker resumes the call.
- One executor class per block type:
BlockExecutor.execute(block, ctx) → Step(next, jump, wait, end) plus commands. Instant blocks (Condition, Go to, Transfer to script, Opt out, App action that doesn't wait, Send and continue) run inside the same turn and continue. - Never fail the caller: exception → system "sorry" + goodbye; log + alert; never an empty turn.
- Call trail: every block visited, key, speech, match, request result and AI stage stored in
call_events→ per-block stats ("60% pressed 1", "25% said it"). - Guards: 25 block visits per call without caller input, max call length, max wait, voicemail length, 5 script transfers.
12. Cost per block
| 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 |