Guide - Using AI Telephone Bots
AI Telephone Bots can run as voice agents or advanced IVR flows. They can gather data, call external systems, and perform call-control actions.
Contents
- Basic Configuration
- Models and Language
- Data Location
- Text-to-Speech
- Language Gate
- Speech Input and Guarding
- Tools and Permissions
- Variables and Session Values
- Contexts, Steps, and Actions
- Webhooks
- RAG Search Tool
- Execution Notes
Basic Configuration
AI Bots are configured under console -> Stuff -> Add -> AI Bot.
A valid config must include a root description.
description: >
You are an AI telephone bot for Widgets Ltd.
Welcome the caller and collect their name.
initial: Thank you for calling Widgets Ltd, please tell me your name.
initial is optional. If set, it is spoken as the first assistant message for that scope (root/context/step).
Models and Language
If model is omitted (or unknown), the bot defaults to mistral-large-2512.
Currently supported model keys:
gpt-3.5-turbogpt-4ogpt-4o-minigpt-4.1gpt-4.1-minigpt-4.1-nanomistral-large-2512mistral-small-2506(retired by Mistral 31st July 2026 — usemistral-small-2603)mistral-small-2603
temperature is clamped to 0..2.
Optional language should be an ISO 639 code and is used by speech-to-text hints.
Data Location
Some organisations need to say where a call may be processed. The optional
region key restricts every AI model the bot can reach to providers processing
in the regions you list.
description: >
You are an AI telephone bot for Widgets Ltd.
region: [ uk, eu ]
model: mistral-large-2512 # eu
stt:
engine: voxtral # eu
tts:
engine: polly # uk
It takes a single region or an array, from uk, eu and us. Leave it out
entirely for no restriction.
Everything the bot can reach is checked: the main model, guards at bot, context and step level, the voicemail and language-gate classifiers, webhook translation, and the text-to-speech and speech-to-text engines including any per-language overrides.
Where each provider processes:
uk |
eu |
us |
no guarantee | |
|---|---|---|---|---|
| Model | mistral-* |
gpt-* |
||
| Speech-to-text | voxtral |
openai-whisper |
cloudflare |
|
| Text-to-speech | polly |
mistral |
inworld |
What happens if a bot breaches its region
The bot is rejected before the call is answered. The caller is passed on to whatever the rule's next step is — a person, or voicemail — so they are never left on a dead call, and the reason is recorded in the call history.
Nothing is quietly swapped for a compliant alternative. A substituted model would mean the bot that ran was not the one that was signed off, and the call record has to stay truthful about what processed the call.
Three things to know
Regions mean exactly what they say. eu does not satisfy uk. If you
accept EU processing for a UK service that is a decision for you to make and
state, by writing region: [ uk, eu ].
Defaults are checked the same as models you name. The default text-to-speech
engine is polly (uk), so region: eu on its own will fail — you also need
tts.engine: mistral. Likewise the language gate's default speech-to-text
engine is cloudflare, so a language gate plus any region
needs languagegate.stt.engine set to an in-region engine.
Cloudflare has no residency guarantee. It is anycast, so we cannot say which country the processing happened in. It satisfies no restriction at all.
Every AI bot call records where its models ran, whether or not a region was
set, so you can evidence it after the fact.
Text-to-Speech
Text-to-speech engine and voice can be configured:
tts:
engine: polly # or mistral, or inworld
voice: Amy # Polly voice ID, Mistral voice slug, or Inworld voice name
Supported engines:
polly(default) — AWS Polly voicesmistral— Mistral voices (plain text only, no SSML)inworld— Inworld voices using theinworld-tts-2-flashmodel (plain text only, no SSML)
Polly voices
Any AWS Polly voice ID can be used (e.g. Amy, Brian, Emma, Joanna, Matthew). The default is Amy. See the AWS Polly voice list for all available voices.
Mistral voices
The full list of supported Mistral voice slugs:
| Voice | Variants |
|---|---|
nick |
— |
alison |
alison_excited |
en_paul |
en_paul_neutral, en_paul_happy, en_paul_sad, en_paul_excited, en_paul_confident, en_paul_cheerful, en_paul_frustrated, en_paul_angry |
gb_oliver |
gb_oliver_neutral, gb_oliver_excited, gb_oliver_curious, gb_oliver_confident, gb_oliver_cheerful, gb_oliver_sad, gb_oliver_angry |
gb_jane |
gb_jane_neutral, gb_jane_confident, gb_jane_curious, gb_jane_sad, gb_jane_confused, gb_jane_frustrated, gb_jane_jealousy, gb_jane_sarcasm, gb_jane_shameful |
fr_marie |
fr_marie_neutral, fr_marie_happy, fr_marie_excited, fr_marie_curious, fr_marie_sad, fr_marie_angry |
Inworld voices
Inworld synthesis uses the inworld-tts-2-flash model, generated at a 16kHz sample rate and resampled internally for telephony. The voice ID is the Inworld voice name (e.g. Ashley, Dennis, Sarah); the default is Ashley. Browse the full voice library in the Inworld TTS documentation.
tts:
engine: inworld
voice: Ashley
Per-language voices
When a bot handles more than one language, tts.voices can override the voice (or the whole engine) per language tag. The flat engine/voice settings remain the default for any language without an override:
tts:
engine: polly
voice: Amy
voices:
fr: { engine: mistral, voice: fr_marie_neutral }
cy: Gwyneth # string shorthand - same engine, different voice
Language Gate
An optional multilingual gate can run at the start of the call: it plays a greeting in each offered language, listens for the caller's choice, and sets the call's language for the rest of the conversation (TTS voice and STT hints follow it automatically).
languagegate:
languages: [ en, fr, ar-LB, uk ] # offered languages (supported: en, fr, ar-LB, uk, cy, it)
default: en # used when no answer can be resolved
greeting:
text: # spoken per language, in that language's voice
en: For English, say English.
fr: Pour le français, dites français.
reprompt: # optional short retry prompt, spoken instead of the full greeting
text:
en: Sorry - which language?
The greeting plays while the gate listens underneath. The caller starting to speak pauses the greeting (instant feedback); a cough or background noise resumes it from where it paused; a real answer stops it and selects the language. An LLM classifier interprets natural answers — the language's name in any language ("Arabic", "عربي", "arabi"), simply speaking in one of the offered languages, or positional references ("the first one").
Options:
greeting.audio/reprompt.audio— pre-rendered wav instead of per-languagetextdecider: keyword— deterministic matching only (no LLM cost); providematchers: { en: [ english, yes ], ... }classifier.model— model override for the LLM decider (defaults tomistral-small-2506)stt.repeat— how many times the greeting repeats before falling back todefault(default 2)stt.silence— trailing listening window in seconds (default 5)- power/duration knobs as in Speech Input (
stt.startabovepoweretc.) stt.engine— speech-to-text engine for the gate turn (defaults tocloudflare). If the bot sets aregionyou must name an in-region engine here, because cloudflare carries no residency guarantee.
Per-language TTS voices are configured on the scenario tts block:
tts:
engine: polly
voice: Amy
voices: # per-language overrides (string voice, or an object to override engine too)
fr: Lea
ar-LB: { engine: polly, voice: Zayd }
Per-language STT overrides use stt.perlanguage keyed the same way.
Speech Input and Guarding
Speech-to-text can be configured with:
stt:
engine: voxtral # or openai-whisper, or cloudflare
mode: concurrent # recommended - natural turn-taking (see below)
Natural turn-taking (concurrent mode)
With mode: concurrent the bot listens continuously — while its reply plays and while it is thinking — instead of speaking and listening in strict turns:
- The caller can interrupt. The reply pauses the moment the caller starts speaking (instant feedback that they were heard). Real speech cuts the reply off and is answered; the bot never talks over the caller.
- Coughs and background noise cost nothing. If a capture turns out not to be speech, the reply resumes from exactly where it paused — not from the start.
- Slow speakers are not cut off. The model can judge that the caller paused mid-sentence or mid-number (an address or reference given in parts) and wait silently for them to finish, answering the completed thought once.
- Speech while the bot is thinking is kept. If the caller adds something during the model's response latency ("oh — and use my mobile number"), the bot answers the addendum instead of the superseded question.
The last two behaviours are driven by two internal tools (ignore_input and await_more_input) that are made available to the model automatically on concurrent scenarios — no configuration is needed.
Tuning (all optional, sensible defaults):
stt:
engine: voxtral
mode: concurrent
startabovepower: 250 # speech-start threshold; lower for quiet callers, raise if line echo falsely pauses the reply
poweraveragepackets: 10 # smoothing window (packets of 20ms) for the speech-start detector
finishbelowpower: 80 # end-of-utterance threshold
minduration: 1000 # minimum capture length in ms
maxduration: 10000 # maximum capture length in ms
silence: 10 # listening window in seconds after the reply finishes
maxturns: 8 # noise re-captures per turn before giving up
Legacy barge-in (sequential mode)
Without mode: concurrent the bot speaks its reply in full and then listens. Barge-in can still be enabled on this sequential path:
stt:
engine: voxtral
interrupt: true # enable barge-in
bargeinpower: 10 # power threshold for barge-in detection
bargeinpoweraveragepackets: 5 # packets to average for power detection
When interrupt is true, speech during prompt playback interrupts the prompt and starts recording immediately. bargeinpower and bargeinpoweraveragepackets control the sensitivity. Note that this cuts the prompt on any energy (a cough kills the reply permanently) — prefer mode: concurrent, which pauses instead and can resume.
For stricter turn-taking, use guard.in at root, context, or step scope (same precedence as other scoped settings: step -> context -> root).
guard.in supports:
description: instruction text for the guard classifier (JEXL templates supported)model: optional model override for guard classification (defaults tomistral-small-2603)allow: array of free-text allow rules (JEXL templates supported)correct: boolean to enable speech-to-text correction on caller inputaction: what to do when input is classified bad
Example:
guard:
in:
description: >
Check caller input against the current prompt.
Be lenient to natural phrasing.
model: mistral-small-latest
allow:
- Caller answers the prompt
- Caller asks to speak with a person
action:
reprompt: Sorry, I did not catch that. ${{ prompt }}
Speech-to-text correction
correct: true enables automatic correction of speech-to-text errors and regional dialect artifacts. The guard classifier already receives the prompt and the caller's transcript, so it can infer what the caller likely meant.
This is useful when callers have strong regional accents. For example, a caller from Liverpool asked "are you the patient or the carer?" might produce a transcript of "cara" — with correct: true, the guard will correct this to "carer" before it reaches the main AI.
correct can be used on its own (without description or allow) for correction-only, or combined with guard for both validation and correction:
# correction only
guard:
in:
correct: true
# guard + correction
guard:
in:
description: Check caller input is relevant to the question.
correct: true
allow:
- Caller answers the prompt
action:
reprompt: Sorry, I did not catch that. ${{ prompt }}
Like other guard settings, correct follows step -> context -> root precedence. A step can set correct: false to disable correction inherited from a higher scope.
Runtime behavior
- The spoken prompt is always what is passed to STT.
- If
guard.inis not configured, input is accepted normally. - If
guard.inis configured, a second AI classification pass is run on each captured user input. - When
correct: true, the classifier may also return a corrected version of the transcript which replaces the original before it reaches the main AI. - Guard classifier failures are fail-closed (treated as bad input).
- On bad input,
guard.in.actionruns (reprompt,finish,hangup, orannotate).
Annotate instead of reject
A strict guard can frustrate callers — if the guard rejects unclear input, the caller simply recycles through the same prompt. action.annotate offers a softer alternative: instead of rejecting, the caller's transcript is passed through to the main LLM wrapped in a tag, so the main AI can decide how to handle it (e.g. ask the caller to spell their name).
guard:
in:
description: Check caller input is a plausible name.
action:
annotate: "[guard: possible STT error - proceed with caution] {{input}}"
annotate accepts:
true— use the default template ([guard: possible STT error — proceed with caution] {{input}})- a string — a custom template where
{{input}}is replaced with the caller's raw transcript
When using the longform, action.tool: annotate can be combined with bad and empty templates:
guard:
in:
action:
tool: annotate
bad: "[guard: unclear answer] {{input}}"
empty: "[quiet murmuring, not understood]"
When using annotate, add guidance to the main AI's system prompt telling it how to handle bracketed tags — e.g. "Input wrapped in [...] is metadata from the STT layer; do not read it aloud. If you see [guard: ...], ask the caller to clarify or spell their answer."
Full example: passing the error to the main LLM
Here a patient is asked for their surname. STT often mangles names, so rather than rejecting and making the caller repeat themselves, the guard annotates the transcript and hands it to the main LLM, which already has instructions to ask the caller to spell the name.
description: >
You are a receptionist taking a patient callback request. Your job is to
collect the caller's surname and phone number, then confirm.
When the caller's input arrives wrapped in square brackets (e.g.
"[guard: ...] smith"), treat the bracketed portion as a private note
from the speech-to-text layer — DO NOT read it back to the caller.
If you see "[guard: possible STT error ...]", the transcript is
uncertain. Ask the caller politely to spell their answer letter by
letter instead of repeating the question verbatim.
If you see "[quiet murmuring, not understood]", the caller said nothing
intelligible. Check they can hear you, then repeat the question once.
contexts:
collectname:
description: Ask the caller for their surname.
guard:
in:
description: Check the caller gave a plausible surname.
allow:
- Caller said a recognisable name
- Caller is spelling a name letter by letter
action:
tool: annotate
bad: "[guard: possible STT error - surname unclear] {{input}}"
empty: "[quiet murmuring, not understood]"
steps:
- description: Ask for surname, confirm once you have it, then move on.
Example conversation:
| Turn | Content |
|---|---|
| Assistant | "What is your surname please?" |
| Caller (STT) | "ffff" |
| Guard | classifies as bad → wraps input |
| Main LLM sees | [guard: possible STT error - surname unclear] ffff |
| Assistant | "Sorry, I didn't quite catch that — could you spell your surname for me, letter by letter?" |
| Caller (STT) | "s m i t h" |
| Guard | accepts (matches "spelling a name" rule) |
| Assistant | "Thank you — so that's Smith, is that right?" |
Notice the main LLM picks a more helpful reply than the generic guard reprompt would, because it saw why the guard was suspicious and adapted its strategy.
When to use annotate vs reprompt
- Use
repromptwhen the rejection is deterministic and you want tight control of the error wording (e.g. "I can only accept yes or no."). - Use
annotatewhen the main LLM has richer context and can recover more gracefully — names, free-form symptoms, addresses, anything where "please spell it" or "please describe it differently" is a better recovery than repeating the same question. - Combine them by scope: set a strict
reprompton a yes/no step, and a looserannotateat the context level for open-ended questions.
Tools and Permissions
Built-in tools:
send_smssend_sms_callerjump_extensionforward_callhangupfinish
Example:
tools:
send_sms:
destinations:
- 447700900123
jump_extension:
extensions:
- "1000"
- "1001"
hangup: true
finish: true
Permission resolution order is:
- step-level tools
- context-level tools
- root-level tools
A tool explicitly set to false at a narrower scope is denied even if enabled elsewhere.
hangup and finish can be either:
true(AI providesfinalmessage)- object with
final(fixed message enforced by config)
Example fixed final:
tools:
hangup:
final: Thank you for calling. Goodbye.
Variables and Session Values
Template format is ${{ ... }}.
Runtime vars available under var:
var.nowvar.uuidvar.callerid
Date/time helper functions are available in JEXL compute/template expressions:
now()now("YYYY-MM-DD HH:mm:ss")now("YYYY-MM-DD HH:mm:ss", "UTC")formatdatetime(input, "YYYY-MM-DD")formatdatetime()(defaults to current date/time)yearssince(input)
Useful formatdatetime tokens:
YYYY,YYMM,MDD,DHH,Hmm,mss,sMMM,MMMMddd,dddd
Examples:
session:
today_utc:
compute: now("YYYY-MM-DD", "UTC")
timestamp:
compute: formatdatetime()
patient_dob_display:
compute: formatdatetime(session.dob, "ddd, DD MMM YYYY")
Session values are under session.
You can pre-populate session values in config, including templates and compute expressions:
session:
enquirer_telephone: ${{var.callerid}}
callerid_len:
compute: var.callerid.length
Within logic, last contains the most recent webhook result (e.g. last.success). Where the webhook declares a returns shape, last.data holds the validated values — including any kept from the AI with expose: false — and last.reason says why a response was rejected.
Contexts, Steps, and Actions
Use start to set the first context.
start: intake
contexts:
intake:
description: Collect caller details.
Context Switching
Context switching is controlled by allowed context lists:
contexts.<name>.contextssteps[].contexts
When switching, session values defined in collect are saved.
Steps
Steps are ordered and run one at a time.
contexts:
intake:
description: Ask one question at a time.
steps:
- initial: What is your first and last name?
collect:
first_name:
description: Caller first name
last_name:
description: Caller last name
- description: What is your date of birth?
collect:
dob:
description: Date of birth in YYYY-MM-DD
Important:
- A string step (e.g.
- "ask name") is treated as a stepdescription. - It is not converted to
initial.
when is supported on contexts and steps.
goto is supported on steps for context jumps:
- goto: another_context
Entry Actions
Contexts and steps can define action blocks that run immediately on entry (before normal AI turn):
action:
webhook: submit_case
or
action:
tool: hangup
final: We have what we need. Goodbye.
Supported action targets:
tool: hanguptool: finishwebhook: <name>
Webhooks
Webhooks are function tools the AI can call.
Required webhook keys:
descriptionurlfields
Example:
webhooks:
submit_case:
description: Send case to CRM
url: https://example.com/api/cases
method: POST
content_type: application/json
expect:
status: 200
content_type: application/json
headers:
Authorization: Bearer ${{secret.crm_token}}
fields:
callerid:
type: string
value: ${{var.callerid}}
dob:
type: string
description: Date of birth in YYYY-MM-DD
age:
compute: yearssince(session.dob)
Notes:
- Default method is
POST. - Default request
content_typeisapplication/json. - Supported request bodies: JSON and
application/x-www-form-urlencoded. - For JSON payloads,
pathcan map nested objects. required: falseomits missing/empty values.expect.statustakes a single status code or a list of them, and must match one of them when provided.- Without
expect, success defaults to HTTP200or202. expectanswers whether the call worked. What of the response the AI is allowed to see is a separate question — see Controlling what the AI sees.
Controlling What the AI Sees
Whatever a webhook returns is handed to the AI as the result of the tool call, where it sits in the conversation alongside everything else the bot has been told. Without returns, a JSON response is passed on whole — every field the endpoint sends, however large, and whatever text it contains.
returns declares the shape you expect back. Only declared, validated values reach the AI.
webhooks:
bookappointment:
description: Book an appointment
url: https://example.com/api/book
fields: # what goes out
nhsnumber:
type: string
description: The caller's NHS number
expect: # did the call work?
status: [ 200, 201 ]
content_type: application/json
returns: # what may the AI see?
path: data.booking # where to read from in the response
fields:
reference:
type: string
pattern: "^[A-Z]{2}[0-9]{4}$"
clinician:
type: string
truncate: 60
required: false
patientid:
type: string
expose: false # kept for your logic, never shown to the AI
The AI is told {"reference":"AB1234","clinician":"Dr Who"} and nothing else. Leave returns out and nothing changes from before.
Two optional settings decide what happens to everything else:
| Situation | Setting | Default | Alternatives |
|---|---|---|---|
| Data you did not declare | on_unknown |
strip — dropped |
fail, allow |
| Declared data that is missing, the wrong type, or breaks a rule | on_invalid |
fail — the call fails |
strip |
The defaults suit a live call. An endpoint that starts sending an extra field should not break anything, so data you did not ask for is dropped. Data you did ask for and did not get is a broken agreement, so the call fails — and the AI is told no more than it is told about any other failed webhook.
Rules fail; cuts do not:
max_length,max_items,pattern,enum,minimumandmaximumare rules. Breaking one goes throughon_invalid.truncateon a string andtakeon an array always cut, and never fail.takeis the usual way to limit how much of a list reaches the AI.- A field with a
defaultnever fails either — it falls back to the default.
Other keys:
returns: false— nothing from the response ever reaches the AI. Right for a webhook that only needs to fire.expose: false— capture a value for your own logic (last.data.patientid) without putting it in the prompt. An identifier can go back out to another webhook without the AI ever seeing it.max_bytes— a cap on the whole payload handed to the AI, 4096 by default. A response can satisfy every field you declared and still be far more than belongs in a prompt.path— read from part of the response, either at the top or on an individual field.- Types are
string,number,integer,boolean,object(withfields) andarray(withitems). Nothing is converted: a field declaredstringthat arrives as a number breaks the declaration and is handled byon_invalid. - A plain text response is declared as a value rather than as fields:
returns: { type: string, truncate: 200 }.
Text is always stripped of invisible characters before the AI sees it. The strongest control is still pattern or enum: a value restricted to a reference format cannot carry an instruction to the bot.
Lists and nested objects work the same way:
webhooks:
findslots:
description: Find available appointment times
url: https://example.com/api/slots
method: GET
fields:
day:
type: string
description: The day to look at, YYYY-MM-DD
returns:
type: array
path: slots
take: 5 # only the first five reach the AI
items:
type: object
fields:
id:
type: string
time:
type: string
Webhook Scope by Context/Step
Webhook exposure can be restricted by:
- context-level
webhooks - step-level
webhooks(array or object allow/deny)
This lets you grant webhook access only where needed.
Mailto Webhooks
url: mailto:someone@example.com is supported.
For mailto webhooks, you can define subject and body as template strings or compute objects.
webhooks:
submitprescription:
description: Email prescription request
url: mailto:ops@example.com
subject: New prescription request
body: |
Caller: ${{ session.first_name }} ${{ session.last_name }}
Telephone: ${{ session.enquirer_telephone }}
Recording: https://www.babblevoice.com/a/callexplorer?u=${{ var.uuid }}
Transcript:
${{ fields.data }}
fields:
data:
compute: >
messages|chattext({ caller: session.first_name, assistant: "Bot" })
Rich formatting with markdown
Set format: markdown to send the email as multipart/alternative. The body is rendered as Markdown into HTML for clients that support it, and the raw Markdown is kept as the plain-text fallback. Use **...** for bold, *...* for italic, # Heading, - for lists, etc. The default (format: text) is unchanged.
webhooks:
submitprescription:
description: Email prescription request
url: mailto:ops@example.com
subject: New prescription request
format: markdown
body: |
**Caller:** ${{ session.first_name }} ${{ session.last_name }}
**Telephone:** ${{ session.enquirer_telephone }}
**Recording:** [listen](https://www.babblevoice.com/a/callexplorer?u=${{ var.uuid }})
**Transcript:**
${{ fields.data }}
fields:
data:
compute: >
messages|chattext({ caller: session.first_name, assistant: "Bot" })
RAG Search Tool
You can expose a search tool that queries MiniRAG.
tools:
search:
- url: kb://prescriptions
purpose: NHS prescription policy documents
The AI then receives a search(url, query) function constrained to configured URLs.
Execution Notes
- Root system prompt is always based on root
description. - Active context
descriptionis appended when in a context. - Active step
descriptionis appended when in steps. - Initial prompt precedence is:
- current step
initial - current context
initial - root
initial(only when not in a context) - On context switch or step completion, message history is sliced to the new base index to keep prompts focused.
- AI tool loops and entry-action loops are capped to avoid runaway behavior.
- Guard input retries are limited per prompt to avoid infinite reprompt loops.
- Three consecutive no-input turns (a silent line) end the conversation rather than replaying the prompt forever.
- On concurrent scenarios the internal turn-taking tools (
ignore_input,await_more_input) are budgeted per caller turn so the model cannot wait indefinitely. - A bot whose models breach its
regionis rejected before the call is answered; the caller passes on to the rule's next step. See Data Location.
Practical Recommendation
Keep permissions tight:
- expose only required tools
- expose only required webhooks per context/step
- prefer deterministic
action+whenfor critical workflow transitions