# Scripts & Blocks Reference

> **Client app developers: read [api/5-flow-reference.md](api/5-flow-reference.md) instead.** It is checked against
> the engine's code. This file is the engine's design notes (worker commands, execution, costs); some names here are
> older than the code (e.g. Condition `test.kind`, Go to `target`).

> The one system that decides what happens on **incoming calls**, **call-outs** (broadcasts, campaigns) and **AI** conversations.
> Built by customers in the client app's Voice Studio (block canvas). Companion to `SPEC.md`, `ENGINE.md`, `ARCHITECTURE.md`, `VOICE-WORKER.md`, `CLIENT-APP-ACTIONS.md`, `SCENARIOS.md`, `EXAMPLES.md`.
> **SIP trunk only (2026-11-12).** Blocks run as commands for the voice worker inside the call's LiveKit room; no AT XML. The Voice API version of this file is in `archive/`.

---

## 1. Concepts

A **script** is a graph of **blocks**. Each block does one thing and has named **exits** wired to the next block. An unconnected exit ends the call (with the account's goodbye if the call is still up).

| 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:

```json
{ "src": "text",    "text": "Habari {name}", "lang": "sw" }
{ "src": "record",  "media": "med_123" }
{ "src": "upload",  "media": "med_456" }
{ "src": "library", "media": "sys_hold_default" }
```
(The Swahili example reads "Hello {name}".)

- `text` → filled, turned into words where needed (§3), then turned into audio by the engine's TTS and **cached**: the same text and voice is generated once and reused by every call (ARCHITECTURE §5.2). Max 500 characters.
- **Voice and speed** (who speaks, how fast). The client sets them where they want; the nearest wins:
  1. on the message: `{ "src": "text", "text": "…", "voice": "cedar", "speed": 1.2 }`
  2. on the block: `"voice": "cedar", "speed": 0.9` next to `type` (applies to every message the block says, including its retries and system lines)
  3. in the script: `settings.voice` (`"cedar"` or per language `{ "sw": "marin", "en": "cedar" }`), `settings.speed` (number or per language)
  4. the engine default (`ringmaster.tts.voice`, speed 1.0)
- Speed runs 0.7 (slower) to 1.5 (faster), rounded to steps of 0.05; 1.0 is normal.
- Voices (OpenAI): female-sounding `marin`, `coral`, `shimmer`, `sage`; male-sounding `cedar`, `ash`, `echo`, `ballad`, `verse`.
- The audio cache keys on voice and speed (`cedar@1.2`), so changing either makes new audio; normal speed keeps the plain name.
- The AI Agent sets its own: `agent.voice` and `agent.speed` (`agent.openaiVoice` is the older name for the voice).
- `record` / `upload` / `library` → the worker plays the file; it must be READY.
- A prompt is a message or a list of messages: `"msg": [ {…text…}, {…library…} ]`.
- Missing media at call time → system "sorry" message, then goodbye; alert.

---

## 3. Variables and metadata

**Variables** are typed values that blocks read aloud, send, or test. Written as `{name}`.

| 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

```json
{
  "scriptId": "scr_debt", "version": 3, "direction": "OUTGOING",
  "settings": { "record": "policy", "maxCallMin": 15, "language": "sw", "voice": { "sw": "marin", "en": "marin" } },
  "vars": [ { "name": "name", "type": "text" }, { "name": "amount", "type": "money" } ],
  "start": { "out": "say_1" },
  "blocks": {
    "say_1":  { "type": "say", "msg": { "src": "text", "text": "Habari {name}. Deni lako ni {amount}." }, "x": { "next": "menu_1" } },
    "menu_1": { "type": "menu", "msg": { "src": "text", "text": "Bonyeza 1 au sema 'lipa' kulipa sasa, 2 kuongea na mhasibu, 9 kujiondoa." },
                "options": [ { "k": "1", "id": "pay",   "label": "Lipa",     "words": ["lipa", "kulipa", "malipo"] },
                             { "k": "2", "id": "staff", "label": "Mhasibu",  "words": ["mhasibu", "mtu", "kuongea"] },
                             { "k": "9", "id": "out",   "label": "Jiondoe",  "words": ["jiondoa", "acha"] } ],
                "speech": { "match": "words" }, "timeoutSec": 8,
                "x": { "pay": "pay_1", "staff": "connect_1", "out": "optout_1", "noInput": "bye_1", "notUnderstood": "menu_1" } },
    "pay_1":  { "type": "app_action", "action": "pay", "params": { "amount": "{amount}", "wallet": "M-Pesa" },
                "wr": { "preset": "Payment" }, "x": { "paid": "bye_2", "failed": "sms_1", "timeout": "sms_1" } }
  }
}
```
Each block: `type`, block settings, optional `before` (announcement message), optional `wr` (Waiting Room), `x` (exit → block id; missing = end).
(The Swahili lines read: "Hello {name}. Your debt is {amount}." · "Press 1 or say 'pay' to pay now, 2 to talk to the accountant, 9 to opt out.")

---

## 5. Blocks (complete list: 17)

⚠ = needs approval (the whole script version).

### Speak
| 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.
```json
"language": { "mode": "FIXED", "languages": ["sw"] }      // or { "mode": "AUTO", "default": "sw" }
```
The detected language is saved as `{ai.language}` (and on the call), so later blocks and the client app know it. With AUTO and sw/en it also sets `{lang}`, so later system messages follow the caller.

### 8.1 Smart matching (Ask blocks)
- Menu, Poll and Pick with `"speech": {"match": "smart"}`: after word matching fails, the engine asks the AI which option the caller meant, given the options' labels and words. Answer below `minConfidence` (default 0.5) → `notUnderstood`.
- `{heard}` holds the text; the timeline records `MATCH {option, by: key | words | smart, confidence}`.
- Without an OpenAI key (`openai.api-key=sim-not-set`) the engine uses a simulated adapter: word matching only; the turn driver sends speech as text.

### 8.2 AI Agent
Designed in the **Agent canvas** (double-click the block).

```json
{
  "goal": "Book an appointment", "persona": "…", "voice": "marin",
  "guard": "Never promise prices not in the knowledge base", "maxMin": 5,
  "kb": { "items": [ { "q": "Saa za kazi", "a": "…" } ], "docs": ["bei_2026.pdf"], "site": "https://…" },
  "fields": [ { "name": "patient_name", "type": "Text", "desc": "Jina lako ni nani", "req": "Required" },
              { "name": "date", "type": "Date", "req": "Required" } ],
  "tools": [ { "type": "Use saved request", "name": "book_appointment", "req": "request_1" },
             { "type": "App action", "name": "send_confirmation", "action": "send_sms", "params": { "text": "Miadi yako ni {ai.date}" } } ],
  "start": "s1",
  "anytime": [ { "kind": "person", "to": "s7" }, { "kind": "upset", "to": "s7" } ],
  "stages": {
    "s1": { "type": "greet", "say": "Habari! Nikusaidie nini leo?", "leave": [ { "kind": "always", "to": "s2" } ] },
    "s2": { "type": "understand", "leave": [ { "kind": "question", "to": "s3" }, { "kind": "intent", "to": "s4" } ] },
    "s3": { "type": "answer", "kb": true, "leave": [ { "kind": "always", "to": "s2" } ] },
    "s4": { "type": "collect", "collect": ["patient_name", "date"], "leave": [ { "kind": "done", "to": "s5" } ] },
    "s5": { "type": "act", "actions": ["book_appointment", "send_confirmation"], "leave": [ { "kind": "success", "to": "s6" }, { "kind": "failed", "to": "s7" } ] },
    "s6": { "type": "confirm", "leave": [ { "kind": "always", "to": "exit:goal" } ] },
    "s7": { "type": "handover", "ho": {
              "ok":     { "say": "Nakuunganisha na mhudumu sasa hivi.", "to": "exit:human" },
              "busy":   { "say": "Wahudumu wote wako kwenye simu…",     "to": "exit:human" },
              "none":   { "say": "Tutakupigia ndani ya saa moja.",       "to": "exit:cb" },
              "closed": { "say": "Tumefunga sasa… acha ujumbe.",         "to": "exit:vm" } } }
  }
}
```
(The Swahili reads: "What are your working hours" · "What is your name" · "Your appointment is on {ai.date}" · "Hello! How can I help you today?" · "I'm connecting you to an agent right now." · "All agents are on calls…" · "We'll call you back within an hour." · "We're closed now… leave a message.")

| 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 |