Skip to main content

Scripts & Blocks Reference

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

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


1. Concepts

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

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

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

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

1.1 Layers

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

1.2 How blocks become worker commands

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

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

  • 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:
    1. on the message: { "src": "text", "text": "…", "voice": "cedar", "speed": 1.2 }
    2. on the block: "voice": "cedar", "speed": 0.9 next to type (applies to every message the block says, including its retries and system lines)
    3. in the script: settings.voice ("cedar" or per language { "sw": "marin", "en": "cedar" }), settings.speed (number or per language)
    4. the engine default (ringmaster.tts.voice, speed 1.0)
  • Speed runs 0.7 (slower) to 1.5 (faster), rounded to steps of 0.05; 1.0 is normal.
  • Voices (OpenAI): female-sounding marin, coral, shimmer, sage; male-sounding cedar, ash, echo, ballad, verse.
  • The audio cache keys on voice and speed (cedar@1.2), so changing either makes new audio; normal speed keeps the plain name.
  • The AI Agent sets its own: agent.voice and agent.speed (agent.openaiVoice is the older name for the voice).
  • record / upload / library → the worker plays the file; it must be READY.
  • A prompt is a message or a list of messages: "msg": [ {…text…}, {…library…} ].
  • Missing media at call time → system "sorry" message, then goodbye; alert.

3. Variables and metadata

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

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: money 45000 → "shilingi elfu arobaini na tano" (sw) / "forty-five thousand shillings" (en); digits 0712… → read one by one; date → "tarehe 20 Novemba" (sw). In Send request URLs/bodies and App action params the raw value is used. [VERIFY: whether the TTS voice reads digits well enough to skip the conversion]

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


4. Script document

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

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


5. Blocks (complete list: 17)

⚠ = needs approval (the whole script version).

Speak

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

Ask: keys, speech, or both

All Ask blocks share one rule set:

  • Keys always work. A block also listens for speech when it has a speech setting. In the Studio this follows Twilio's pattern: connecting the block's "said something" exit turns speech on.
  • Barge-in: the caller can press a key or start talking during the prompt; the prompt stops.
  • Type-ahead: keys pressed during an interruptible Say carry into the next Ask.
  • No input after timeoutSec (counted from the end of the prompt) → retry (up to retries, max 3) → noInput.
  • Not understood (speech that matches nothing) → "Samahani…" ("Sorry…") retry → notUnderstood. Only on blocks that listen for speech.
  • Speech is cut by the worker (VAD) and transcribed by the engine (gpt-transcribe, the block's language, the options' words as hints). The text is saved as {heard} and in the timeline (SPEECH).
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; meta object always added (callId, callOutId, block, meta.client).
  • Reply: 2xx = success (empty 200 = success, nothing mapped); 4xx/5xx = failure; no answer within timeout (default 10 s, max 15 s while the caller waits) = timeout. JSON paths mapped to variables (data.status → {status}).
  • HTTPS only; DNS-resolved private / loopback / link-local / metadata IPs blocked; no redirects; response ≤ 64 KB.
  • Send and continue: queued, retried up to 24 h; never delays the caller.
  • Wait for callback (slow systems): the body includes {callback_url}, a one-time signed URL for this call + block. The caller waits in the Waiting Room until the business POSTs the result there (mapped like a reply) or the give-up time passes. If the caller already hung up, the result is still stored and reported (call.block).
  • Reply conveniences: a top-level say string can be spoken as-is ({reply.say}, made into audio at the turn); a top-level items array feeds Pick from list. Recommended reply contract: EXAMPLES.md §0.1.
  • Secrets (tokens, passwords) stored encrypted; never shown again in full.

Logic

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

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

End

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 with countAs (e.g. paid), Opt out → opted_out, AI Agent goal → goal, Answered by machine → machine.
  • Sensitive (Collect number): the value is usable in the call (e.g. sent to a verify request) but stored and shown as ••••, never in exports or webhooks. OTP codes are always sensitive. A sensitive Collect never listens for speech (no transcript of a PIN).

6. Announcement (any block)

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

Automatic, not editable per block:

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

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

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

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.start or wr.updates (e.g. "Bonyeza 9 kuacha ujumbe", "Press 9 to leave a message"). Options with neither give the warning WAIT_OPTIONS_UNANNOUNCED.

8. AI

8.0 AI language

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

  • Fixed: listen and speak only the listed language(s), e.g. ["sw"] (Kiswahili only) or ["sw","en"]; a caller speaking another language is answered in the first one.
  • Auto: detect the caller's language from their speech and answer in it, switching if the caller switches.
"language": { "mode": "FIXED", "languages": ["sw"] }      // or { "mode": "AUTO", "default": "sw" }

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

8.1 Smart matching (Ask blocks)

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

8.2 AI Agent

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

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

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


8a. Block settings (exact JSON)

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 get meta {callId, callOutId, block, client}; form bodies get meta.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-level say → {reply.say}; top-level items feeds Pick from list.
  • Waiting: a reply within ~1 s is used at once, with no Waiting Room; otherwise the caller goes to the Waiting Room and the engine wakes the worker when the reply arrives.
  • Wait for callback: the system posts the result to {callback_url} (POST /hooks/resume/{token}, one-time, valid until the give-up time). "result" (or "status") = failure / failed / error / declined / cancelled → failure exit, anything else → success; the body is mapped with map. After the caller left, the result is stored and sent as call.block with late: true. Answers: 200, 404 unknown, 409 already answered, 410 expired.
  • Send and continue: queued (stored encrypted), retried on 5xx / 408 / 429 / timeout / network errors at 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h… for up to 24 h; a final failure sends request.failed.
  • Secrets: auth.token, auth.password, auth.value are encrypted when the version is saved (enc: prefix) and decrypted only to send.
  • App actions: CLIENT-APP-ACTIONS.md (event, answer, outcomes, timeouts).
  • Sensitive Collect: the value is kept encrypted in the call state, readable by later blocks ({input.pin}), shown as •••• in keys, timeline, webhooks and any spoken-text log.

9. Validation (on save / before publish)

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

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

10. Versioning & approval

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

11. Execution (engine)

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

12. Cost per block

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