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 |
No comments to display
No comments to display