Route calls
Forward calls, build phone menus and route callers by time, intent or language.
Forward calls to your team
Two mobiles and a SIP PBX ring at the same time without answering first; whoever picks up gets the call and sees the caller's number.
How it works
- Someone calls your number and hears the ringing tone.
- Your two mobiles and your PBX ring at the same time, showing the caller's number.
- The first one to pick up gets the call; the others stop ringing.
- If nobody answers, the caller hears a short apology.
Starts on: inbound-call
Actions used: dial@v2, answer@v1, play@v3, hangup@v1
Guide: SIP Connectivity
# Forwards every call to two mobiles and a SIP PBX at the same time.
# The call is not answered first: the caller hears the ringing tone,
# and whoever picks up first gets the call with the caller's number.
# If nobody answers, the caller hears a short apology.
#
# What to edit: the numbers and the SIP address in targets,
# and the apology text. The apology is in English: for French, set
# defaults.language to fr-FR and translate it, e.g.
# "Désolé, personne n'est disponible. Merci de rappeler plus tard."
#
# Needs: a number assigned to this scenario. In Build Mode,
# calls only reach your Testing Numbers.
#
# Caller IDs: https://docs.callr.com/docs/caller-ids
# SIP: https://docs.callr.com/docs/sip-connectivity
description: "Forward calls to your team"
compat:
version: "2025-09-01"
defaults:
# Do not answer the call: the forwarding is transparent and the
# call is only answered if someone picks up, or to play the apology.
autoAnswer: false
language: en-US
variables:
$dial: null
branches:
inbound-call:
actions:
- action: dial@v2
params:
# 'clone' (the default) shows the caller's number to your team.
# Use 'restricted' to hide it, or one of your approved caller IDs.
callerId: clone
# true rings all targets at the same time; false calls them one after the other.
# The first target to answer is bridged, the others are canceled.
parallel: true
targets:
- number: "+33639980000"
timeout: 20
- number: "+33639980001"
timeout: 20
# Your PBX or SBC. For SIP over TLS, use port 5061 and
# append ;transport=tls, e.g. sip:[email protected]:5061;transport=tls
- number: sip:${{ call.toNumber }}@sip.example.com:5060
timeout: 30
result: $dial
# Nobody picked up: answer and apologise.
- if: "!$dial?.ok"
then:
- action: answer@v1
- action: play@v3
params:
tts:
text: Sorry, nobody is available. Please call back later.
- action: hangup@v1Simple phone menu
The classic IVR: a greeting, one key per department, opening hours on 3, a second chance when nothing is pressed, and a message when the team does not answer.
How it works
- Someone calls your number and hears a short welcome menu.
- They press 1 to reach sales, 2 to reach support, or 3 to hear your opening hours.
- If they press nothing or a wrong key, the menu is played once more, then the call goes to sales.
- If the team does not answer, the caller hears a message asking them to call back later.
Starts on: inbound-call
Actions used: gather@v4, play@v3, dial@v2, hangup@v1
Guide: Build an IVR Menu
# Phone menu (IVR): callers press 1 for sales, 2 for support,
# 3 to hear your opening hours. If nothing or a wrong key is pressed,
# the menu is played once more, then the caller goes to sales. If the
# team does not answer, the caller hears a message before the call ends.
#
# What to edit: the $sales and $support numbers, $hoursText and the
# welcome sentence of the menu.
# Needs: nothing else, text-to-speech only.
# Build Mode: calls can only be forwarded to your Testing Numbers.
# Guide: https://docs.callr.com/docs/ivr-menu
description: "Simple phone menu"
compat:
version: "2025-09-01"
defaults:
language: en-US
voice: en-US-Harper:MAI-Voice-2
variables:
$menu: null
$dial: null
# MODIFIABLE VARIABLES BELOW
$sales: "+33199001020" # key 1: your sales team (international format)
$support: "+33199001030" # key 2: your support team (international format)
$hoursText: We are open Monday to Friday, from 9 AM to 6 PM. # key 3: the opening hours read to the caller
branches:
inbound-call:
actions:
# Play the menu and wait for ONE key
- action: gather@v4
params:
tts:
text: Welcome. For sales, press 1. For support, press 2. For our opening hours, press 3.
maxDigits: 1
timeout: 12 # includes the time to play the menu
result: $menu
# Alternative: play a recording of your library instead of text-to-speech
# - action: gather@v4
# params:
# audioFile:
# sid: abcd1234 # your menu recording
# maxDigits: 1
# timeout: 15 # make it longer than your recording
# result: $menu
# Nothing pressed, or not a valid key: ask once more
- if: $menu?.timeout || !['1', '2', '3'].includes($menu?.input)
action: gather@v4
params:
tts:
text: Press 1 for sales, 2 for support, or 3 for our opening hours.
maxDigits: 1
timeout: 12
result: $menu
# Key 1, or still no valid key after the second try: sales
- if: "!['2', '3'].includes($menu?.input)"
then:
- action: play@v3
params:
tts:
text: Connecting you to sales.
- action: dial@v2
params:
targets:
- number: ${{ $sales }}
timeout: 30
result: $dial
# Alternative: ring two sales phones at the same time (ring group)
# - action: dial@v2
# params:
# targets:
# - number: "+33199001020"
# timeout: 30
# - number: "+33199001021"
# timeout: 30
# parallel: true # the first one to answer gets the call
# result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Our sales team is not available right now. Please call back later.
# Key 2: support
- if: $menu?.input === '2'
then:
- action: play@v3
params:
tts:
text: Connecting you to support.
- action: dial@v2
params:
targets:
- number: ${{ $support }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Our support team is not available right now. Please call back later.
# Key 3: opening hours
- if: $menu?.input === '3'
action: play@v3
params:
tts:
text: ${{ $hoursText }} Goodbye.
- action: hangup@v1Speech menu with status lookup
Callers press or say their choice: reach an agent, or give a ticket number that is looked up in your API and read back to them.
How it works
- When someone calls, a menu asks them to press 1 or say agent, or press 2 or say ticket.
- Choosing agent transfers the call to your agent's phone.
- Choosing ticket asks for the ticket number on the keypad and reads it back for confirmation.
- Your API is asked for the ticket status, which is read to the caller.
- After three failed attempts the caller is sent to an agent, so nobody is stuck in the menu.
Starts on: inbound-call
Actions used: play@v3, recognize@v3, javascript@v1, dial@v2, hangup@v1, gather@v4, fetch@v3
Guide: Build an IVR Menu
# IVR with speech recognition and self-service status lookup
#
# Callers press a key or say their choice: "agent" transfers the call,
# "ticket" asks for a ticket number, reads it back for confirmation,
# looks it up in your API and reads the status to the caller.
#
# What to edit: $agentNumber, the prompts, and the fetch@v3 URL
# (replace the demo API by your own ticket status endpoint).
# Needs: an API that returns the ticket status as JSON.
# Build Mode: calls go only to your Testing Numbers.
# Docs: https://docs.callr.com/docs/ivr-menu
description: "Speech menu with status lookup"
compat:
version: "2025-09-01"
defaults:
language: en-US
voice: en-US-Harper:MAI-Voice-2
variables:
$count: 0 # wrong answers in the main menu
$retries: 0 # wrong answers while entering a ticket ID
$dial: null
# MODIFIABLE VARIABLES BELOW
$agentNumber: "+15550100011" # the agent who takes the calls
branches:
inbound-call:
actions:
- action: play@v3
params:
tts:
text: Welcome to our smart IVR.
- label: menu
action: recognize@v3
params:
stopOnDTMF: true
tts:
text: |
Press 1 or say "agent" to talk to someone.
Press 2 or say "ticket" if you have a ticket ID.
timeout: 15
hints:
- agent
- ticket
result: $menu
- if: $menu?.dtmf == '1' || ($menu?.lexical || '').toLowerCase().includes('agent')
goto:
branch: agent
- if: $menu?.dtmf == '2' || ($menu?.lexical || '').toLowerCase().includes('ticket')
goto:
branch: ticket
- action: play@v3
params:
tts:
text: I'm sorry, I did not get that.
- action: javascript@v1
params:
code: $count++
# After 3 failed attempts, the caller is transferred to an agent
- if: $count >= 3
then:
- action: play@v3
params:
tts:
text: Let me transfer you to an agent.
- goto:
branch: agent
- goto:
label: menu
agent:
actions:
- action: dial@v2
params:
targets:
- number: ${{ $agentNumber }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Sorry, no one is available. Please call back later.
- action: hangup@v1
ticket:
actions:
# Each ticket lookup gets its own 3 attempts
- action: javascript@v1
params:
code: $retries = 0
- label: gather
action: gather@v4
params:
maxDigits: 12
timeout: 20
endOnKey: "#"
tts:
text: Please enter your ticket ID, followed by the hash key.
# Or play an audio file from your library instead of text-to-speech
# (replace "tts" above, set at most one of tts or audioFile):
# audioFile:
# sid: "fdsanjkf"
result: $ticketID
# After 3 failed attempts, the caller is transferred to an agent
- if: "!$ticketID?.input"
then:
- action: javascript@v1
params:
code: $retries++
- action: play@v3
params:
tts:
text: I did not receive your ticket ID.
- if: $retries >= 3
goto:
branch: agent
- goto:
label: gather
- action: javascript@v1
params:
# this will transform "123" into "1 2 3"
# we will use this for speech in the next action
code: return $ticketID.input.split('').join(' ')
result: $ticketIdWithSpaces
- label: confirm
action: recognize@v3
params:
stopOnDTMF: true
tts:
text: >
You have entered ${{ $ticketIdWithSpaces }}.
Is this correct? Press 1 or say yes. Press 2 or say no.
hints:
- "yes"
- "no"
result: $yesno
- if: "!$yesno?.ok"
then:
- action: javascript@v1
params:
code: $retries++
- action: play@v3
params:
tts:
text: I am sorry, I did not get that.
- if: $retries >= 3
goto:
branch: agent
- goto:
label: confirm
# Anything but an explicit yes: enter the ticket ID again
- if: "!($yesno?.dtmf == '1' || ($yesno?.lexical || '').toLowerCase().split(' ').includes('yes'))"
then:
- action: javascript@v1
params:
code: $retries++
- action: play@v3
params:
tts:
text: Sure, let's do this again.
- if: $retries >= 3
goto:
branch: agent
- goto:
label: gather
- action: play@v3
params:
tts:
text: Thank you. Please hold while I look for your ticket status.
- action: fetch@v3
params:
# this is a free API that returns a random 'yes' or 'no'
url: https://yesno.wtf/api
# with your own API, pass the ticket ID in the query:
# url: https://api.example.com/tickets?ticketID=${{ encodeURIComponent($ticketID.input) }}
timeout: 5
result: $status
- if: "!$status?.ok"
then:
- action: play@v3
params:
tts:
text: >
I am sorry, I could not fetch the ticket status
at this time. Please call back another time.
- action: hangup@v1
- if: $status?.json?.answer == 'yes'
then:
- action: play@v3
params:
tts:
text: Good news! Your phone is repaired and ready to be picked up.
- goto:
branch: inbound-call
- action: play@v3
params:
tts:
text: We are still working on your phone. Maybe tomorrow?
- goto:
branch: inbound-callRoute by opening hours
During opening hours calls ring your whole team; after hours, on holidays or when nobody answers, callers leave a voicemail that is transcribed and emailed to you.
How it works
- When someone calls, the scenario checks the local time in your time zone against your weekly hours and holiday list.
- During opening hours, all your team phones ring at the same time and the first to answer takes the call.
- After hours or on a holiday, the caller hears your closed message instead.
- If you are closed or nobody picks up, the caller leaves a voicemail.
- When the call ends, the message is transcribed and emailed to you with the recording link.
Starts on: inbound-call
Actions used: javascript@v1, log@v1, play@v3, voicebot@v1, email@v1, hangup@v1, dial@v2, record@v1, saveRecordingFile@v1
Guide: Pre-defined Variables
# Business hours routing.
# During opening hours, every call rings your team at the same time.
# After hours, on holidays, or when nobody picks up, callers hear a
# message and leave a voicemail that is transcribed and emailed to you.
#
# What to edit: the MODIFIABLE VARIABLES below (time zone, weekly hours,
# holidays, team numbers, closed message, email recipients).
# Needs: nothing else, text-to-speech and transcription are included.
# Build Mode: calls can only be forwarded to your Testing Numbers.
# Docs: https://docs.callr.com/docs/callr-actions-pre-defined-variables
description: "Route by opening hours"
compat:
version: "2025-09-01"
defaults:
language: en-US
voice: en-US-Olivia:MAI-Voice-2
variables:
$isOpen: false
$localTime: ""
$dial: null
$file: ""
# MODIFIABLE VARIABLES BELOW
$timezone: America/New_York # IANA time zone of your opening hours
$utcOffsetMinutes: -300 # fallback UTC offset if the time zone is unknown (-300 winter, -240 summer), update it when daylight saving time changes
$hours: # opening hours per day: a list of ["HH:MM", "HH:MM"] ranges (24 h), add a second range for a lunch break, [] = closed
mon: [["09:00", "18:00"]]
tue: [["09:00", "18:00"]]
wed: [["09:00", "18:00"]]
thu: [["09:00", "18:00"]]
fri: [["09:00", "18:00"]]
sat: []
sun: []
$holidays: # closed all day on these dates (YYYY-MM-DD)
- "2026-11-26"
- "2026-12-25"
- "2027-01-01"
$team: # team phones, all ringing at the same time during opening hours
- number: "+15550100001"
timeout: 25
- number: "+15550100002"
timeout: 25
$closedText: Thank you for calling. We are closed right now. We are open Monday to Friday, 9 AM to 6 PM. # played after hours
$voicemailEmail: # receive the voicemail recording and transcript
- [email protected]
branches:
inbound-call:
actions:
# Local day, date and time in $timezone, then the opening hours check
- action: javascript@v1
params:
code: |
const pad = (n) => String(n).padStart(2, '0')
let p
let fallback = false
try {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: $timezone, weekday: 'short', year: 'numeric', month: '2-digit',
day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(new Date(now))
p = Object.fromEntries(parts.map((x) => [x.type, x.value]))
} catch (e) {
fallback = true
const d = new Date(new Date(now).getTime() + $utcOffsetMinutes * 60000)
p = {
weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()],
year: String(d.getUTCFullYear()), month: pad(d.getUTCMonth() + 1),
day: pad(d.getUTCDate()), hour: pad(d.getUTCHours()), minute: pad(d.getUTCMinutes()),
}
}
const day = p.weekday.toLowerCase()
const date = p.year + '-' + p.month + '-' + p.day
const time = p.hour + ':' + p.minute
let ranges = $hours?.[day] ?? []
if (typeof ranges[0] === 'string') ranges = [ranges] // a single ["09:00", "18:00"] pair
const hhmm = (t) => String(t).padStart(5, '0') // "9:00" -> "09:00"
const open = !($holidays ?? []).includes(date) &&
ranges.some(([from, to]) => time >= hhmm(from) && time < hhmm(to))
return { $isOpen: open, $localTime: day + ' ' + date + ' ' + time + (fallback ? ' (fallback offset)' : '') }
- action: log@v1
params:
message: "Local time: ${{ $localTime }}, open: ${{ $isOpen }}"
- if: "!$isOpen"
then:
- action: play@v3
params:
tts:
text: ${{ $closedText }}
- goto:
branch: voicemail
# Alternative: let an AI agent take after-hours calls instead of voicemail
# (the "AI voicemail" template has a complete agent). Add an $openaiKey
# variable, then replace the play@v3 and goto above with:
# - action: voicebot@v1
# params:
# engine:
# name: OpenAI
# model: gpt-5.4-mini
# auth: ${{ $openaiKey }}
# initialMessages:
# - role: system
# content: We are closed. Ask for the caller's name and reason for calling, save them with set_value (name, reason), then use the hangup tool.
# - role: assistant
# content: Hello, we are closed right now. I can take a message. What is your name?
# result: $bot
# - action: email@v1
# params:
# to: ${{ $voicemailEmail }}
# subject: After-hours message from ${{ call.fromNumber ?? '(hidden number)' }}
# text: |
# Name: ${{ $bot?.values?.name ?? '-' }}
# Reason: ${{ $bot?.values?.reason ?? '-' }}
# Local time: ${{ $localTime }}
# - action: hangup@v1
# Opening hours: ring every team phone, the first to answer takes the call
- action: dial@v2
params:
targets: ${{ $team }}
parallel: true
result: $dial
- if: "!$dial?.ok"
then:
- action: play@v3
params:
tts:
text: Sorry, nobody is available right now.
- goto:
branch: voicemail
voicemail:
actions:
- action: play@v3
params:
tts:
text: Please leave a message after the tone. Press any key when you are done.
- action: record@v1
params:
maxLength: 120 # seconds
result: $file
- action: play@v3
params:
tts:
text: Thank you. We will call you back. Goodbye.
- action: hangup@v1
# Runs when the call ends: save the voicemail, transcribe it and email it
hangup:
actions:
- if: $file && $voicemailEmail?.length
action: saveRecordingFile@v1
params:
file: ${{ $file }}
events:
ready:
- action: email@v1
params:
to: ${{ $voicemailEmail }}
subject: New voicemail from ${{ call.fromNumber ?? '(hidden number)' }}
text: |
Hello,
You have a new voicemail from ${{ call.fromNumber ?? '(hidden number)' }} to ${{ call.toNumber }}.
Reason: ${{ $isOpen ? 'nobody answered' : 'closed' }}, local time ${{ $localTime }}.
Duration: ${{ recording.duration }}s.
"${{ transcription_leg_a }}"
Listen to the recording: ${{ recording.url }}Screen callers
Unknown callers record their name; you hear it and press 1 or say yes to accept; numbers on your VIP list go straight through.
How it works
- When someone calls, they are asked to say their name and the reason for their call.
- Your phone rings and you hear their recording before the caller is connected.
- Press 1 or say yes to take the call, or press 2 to hear the recording again.
- Numbers on your VIP list skip the recording and are connected right away.
- If you do not answer or your voicemail picks up, the caller hears that you are not available.
Starts on: inbound-call
Actions used: javascript@v1, play@v3, record@v1, dial@v2, amd@v3, hangup@v1, bridge@v3, playRecordingFile@v1, recognize@v3
Guide: Language Reference
# Screen callers: hear who is calling before you accept
#
# Unknown callers record their name and the reason for their call.
# Your phone rings: you hear the recording, then press 1 or say "yes"
# to take the call. Numbers on your VIP list go straight through.
#
# What to edit: $target (your phone) and $vipList.
# Needs: nothing else.
# Build Mode: calls go only to your Testing Numbers.
# Docs: https://docs.callr.com/docs/callr-actions-reference
description: "Screen callers"
compat:
version: "2025-09-01"
defaults:
language: en-US
voice: en-US-Olivia:MAI-Voice-2
variables:
$vip: false
$count: 0
$record: null
$dial: null
# MODIFIABLE VARIABLES BELOW
$target: "+33639980042" # the number that receives the screened calls
$vipList: # these numbers are forwarded without screening
- "+33639980000"
branches:
inbound-call:
actions:
# Bypass VIPs
- if: $vipList.includes(call.fromNumber)
then:
- action: javascript@v1
params:
code: $vip = true
- goto:
label: dial
- action: play@v3
params:
tts:
text: Hello! The person you are trying to reach is using a call screening service.
- action: play@v3
params:
tts:
text: After the tone, please indicate your name and the reason for your call.
# Record the user for 6s maximum
- action: record@v1
params:
maxLength: 6
result: $record
- action: play@v3
params:
tts:
text: Thank you. Please wait while we connect you.
# Call the target
- label: dial
action: dial@v2
params:
answerOnBranch: acceptOrReject
targets:
- number: ${{ $target }}
timeout: 30
result: $dial
# If the target did not accept the call
- if: "!$dial?.ok"
goto:
branch: unavailable
acceptOrReject:
actions:
# Voicemail detection on the target
- action: amd@v3
params:
yesHints:
- voicemail
- leave
- message
- person
result: $amd
# Hangup if voicemail is detected on the target
- if: $amd?.detected == 'machine'
action: hangup@v1
- if: $vip
then:
- action: bridge@v3
- action: hangup@v1
# Whisper to the target
- action: play@v3
params:
tts:
text: This is a screened call.
# Play the recording to the target
- label: who
action: playRecordingFile@v1
params:
file: ${{ $record }}
# Menu to accept the call
- action: recognize@v3
params:
timeout: 10
tts:
text: >
To accept this call, say yes or press 1.
To listen again, say again or press 2.
hints:
- "yes"
- "again"
stopOnDTMF: true
result: $input
# Bridge
- if: $input?.dtmf == '1' || ($input?.lexical || '').toLowerCase().includes('yes')
then:
- action: bridge@v3
- action: hangup@v1
# Anything else: play the recording again (2 times at most)
- action: javascript@v1
params:
code: $count++
# After 2 plays, hangup
- if: $count == 2
action: hangup@v1
- goto:
label: who
unavailable:
actions:
- action: play@v3
params:
tts:
text: Sorry, the person you are trying to reach is not available. Please try again later.
- action: hangup@v1Route by intent with AI
Callers say what they need instead of going through a menu tree: AI picks the right team, with a keypad menu as backup and a person when the AI cannot decide.
How it works
- When someone calls, they hear your welcome and are asked, in a few words, why they are calling.
- AI reads what they said and picks sales, support or billing, while the caller hears a short waiting sound.
- If the caller says nothing, the question is asked once more, then a keypad menu (1, 2, 3) is offered.
- The caller is told which team they are being connected to and the call rings that team.
- Requests the AI cannot place go to your reception desk, and if nobody answers the caller hears a short apology.
Starts on: inbound-call
Actions used: play@v3, recognize@v3, javascript@v1, gather@v4, openai@v3, log@v1, dial@v2, hangup@v1
Guide: Build an IVR Menu
# No menu tree: callers say why they are calling, an AI model sorts
# the request (sales, support, billing) and the call goes to the right
# team. Silence gets one retry, then a keypad menu. Anything the AI
# cannot place goes to a person ($fallback).
#
# What to edit: $openai.key, $teams (numbers, spoken names and the
# description that helps the AI decide), $fallback, the welcome sentence,
# the keypad sentence and the recognize hints (keep them in $teams order).
# Needs: an OpenAI API key.
# Build Mode: calls can only be forwarded to your Testing Numbers.
# Guide: https://docs.callr.com/docs/ivr-menu
description: "Route by intent with AI"
compat:
version: "2025-09-01"
defaults:
language: en-US
voice: en-US-Harper:MAI-Voice-2
variables:
$speech: null
$menu: null
$messages: null
$ai: null
$intent: null
$target: null
$dial: null
$count: 0
# MODIFIABLE VARIABLES BELOW
$openai:
key: <openai-key> # your OpenAI API key
model: gpt-5.4-mini # a fast model keeps the caller waiting less
$teams: # keypad order: 1, 2, 3 (example: a car dealership)
sales:
number: "+33199001020" # international format
name: our sales team # spoken: "Connecting you to ..."
description: buying a car, prices, trade-ins, test drives
support:
number: "+33199001030"
name: our service department
description: repairs, maintenance, breakdowns, service appointments
billing:
number: "+33199001040"
name: our accounting team
description: invoices, payments, refunds, financing
$fallback: # anything else, or no answer from the AI
number: "+33199001010"
name: our reception desk
branches:
inbound-call:
actions:
- action: play@v3
params:
tts:
text: Welcome to Acme Motors.
# The caller speaks freely (a key press also works)
- label: ask
action: recognize@v3
params:
tts:
text: In a few words, tell me why you are calling.
hints: [sales, support, billing]
stopOnDTMF: true
timeout: 12
result: $speech
# Nothing heard: ask once more
- if: "!$speech?.dtmf && !$speech?.lexical && $count < 1"
then:
- action: javascript@v1
params:
code: $count++
- action: play@v3
params:
tts:
text: Sorry, I did not hear you.
- goto:
label: ask
# Still nothing: keypad menu (keys follow the order of $teams)
- if: "!$speech?.dtmf && !$speech?.lexical"
action: gather@v4
params:
tts:
text: For sales, press 1. For service, press 2. For billing, press 3.
maxDigits: 1
timeout: 12
result: $menu
# The caller spoke: the AI picks the team (a short sound plays meanwhile)
- if: "!$speech?.dtmf && $speech?.lexical"
then:
- action: javascript@v1
params:
code: |
const list = Object.entries($teams)
.map(([id, team]) => `- ${id}: ${team.description}`)
.join('\n')
$messages = [
{ role: 'system', content: `You route phone calls. Classify the caller's request into one of these intents:\n${list}\n- other: anything else.\nAnswer with JSON only, for example {"intent": "other"}` },
{ role: 'user', content: $speech.text || $speech.lexical }
]
- action: openai@v3
params:
secret: ${{ $openai.key }}
endpoint: chat/completions
properties:
model: ${{ $openai.model }}
messages: ${{ $messages }}
response_format:
type: json_object
feedback:
enabled: true
sound: loading
timeout: 10
result: $ai
# One team, whether it was spoken, pressed or classified
- action: javascript@v1
params:
code: |
const ids = Object.keys($teams)
const key = $speech?.dtmf || $menu?.input
let intent = key ? ids[Number(key) - 1] : null
if (!key && $ai?.ok && $ai?.choices?.length) {
try {
intent = String(JSON.parse($ai.choices?.[0]?.message?.content).intent ?? '').trim().toLowerCase()
} catch (e) {
intent = null
}
}
$intent = ids.includes(intent) ? intent : 'other'
$target = $teams[$intent] ?? $fallback
- action: log@v1
params:
message: "Intent: ${{ $intent }}, said: ${{ $speech?.text }}"
- action: play@v3
params:
tts:
text: Connecting you to ${{ $target.name }}.
- action: dial@v2
params:
targets:
- number: ${{ $target.number }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Nobody is available right now. Please call back later. Goodbye.
- action: hangup@v1Greet callers in their language
One number for every market: the caller's country picks the greeting language, the voice and the local team that answers, with English by default.
How it works
- Someone calls your number.
- Their country decides the language: French, Spanish, or English for everyone else.
- They hear a greeting in their language, read by a native voice.
- The call is forwarded to the team for that language.
- If nobody answers, they hear an apology in their language.
Starts on: inbound-call
Actions used: play@v3, dial@v2, hangup@v1
Guide: Pre-defined Variables
# One number for every market: the caller's country picks the greeting
# language and voice (French, Spanish or English) and the team that
# answers. Callers from any other country, or with a hidden number,
# get the English greeting and the English team.
#
# What to edit: the country lists and the team numbers below the
# MODIFIABLE VARIABLES line, and the greeting texts in each branch.
# Each branch sets its own language and voice in its defaults: change
# the voice there to suit a market.
# To add a language, copy a branch, set its defaults (language + voice)
# and add one more "if ... goto" line in inbound-call.
# Needs: a number assigned to this scenario.
# Build Mode: calls only reach your Testing Numbers.
# Docs: https://docs.callr.com/docs/callr-actions-pre-defined-variables
description: "Greet callers in their language"
compat:
version: "2025-09-01"
defaults:
# Used by the english branch. The french and spanish branches set
# their own language and voice.
language: en-US
voice: en-US-Olivia:MAI-Voice-2
variables:
$dial: null
# MODIFIABLE VARIABLES BELOW
# Caller countries (ISO codes) that hear the French greeting
$frenchCountries: [FR, BE, CH, LU, MC]
# Caller countries (ISO codes) that hear the Spanish greeting
$spanishCountries: [ES, MX, AR, CO, CL, PE]
# Team that answers French-speaking callers
$teamFrench: "+33199001020"
# Team that answers Spanish-speaking callers
$teamSpanish: "+34910001020"
# Team that answers everyone else, in English
$teamEnglish: "+15550100001"
branches:
inbound-call:
actions:
# call.fromCountry is the caller's country code, e.g. FR
- if: $frenchCountries.includes(call.fromCountry)
goto:
branch: french
- if: $spanishCountries.includes(call.fromCountry)
goto:
branch: spanish
- goto:
branch: english
french:
defaults:
language: fr-FR
voice: fr-FR-Soleil:MAI-Voice-2
actions:
- action: play@v3
params:
tts:
text: Bonjour et merci de votre appel. Nous vous mettons en relation avec notre équipe.
- action: dial@v2
params:
targets:
- number: ${{ $teamFrench }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Désolé, personne n'est disponible pour le moment. Merci de rappeler plus tard.
- action: hangup@v1
spanish:
defaults:
language: es-ES
voice: es-ES-Marta:MAI-Voice-2
actions:
- action: play@v3
params:
tts:
text: Hola, gracias por su llamada. Le pasamos con nuestro equipo.
- action: dial@v2
params:
targets:
- number: ${{ $teamSpanish }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Lo sentimos, no hay nadie disponible en este momento. Por favor, vuelva a llamar más tarde.
- action: hangup@v1
english:
actions:
- action: play@v3
params:
tts:
text: Hello, thank you for calling. We are connecting you to our team.
- action: dial@v2
params:
targets:
- number: ${{ $teamEnglish }}
timeout: 30
result: $dial
- if: "!$dial?.ok"
action: play@v3
params:
tts:
text: Sorry, nobody is available right now. Please call back later.
- action: hangup@v1Keep your PBX over SIP
Inbound calls go to your PBX or SBC over SIP TLS; if it is unreachable or busy they fail over to a mobile, then to a voicemail that is transcribed and emailed to you.
How it works
- Someone calls your number and hears the ringing tone; the call is not answered yet.
- The call is sent to your PBX or SBC over secure SIP, with the caller's number and the number they dialed.
- If your PBX does not pick up within 15 seconds, your backup mobile rings for 30 seconds.
- If nobody answers, the caller is invited to leave a message.
- The message is transcribed and emailed to you with a link to the audio file.
Starts on: inbound-call
Actions used: dial@v2, answer@v1, play@v3, record@v1, hangup@v1, saveRecordingFile@v1, email@v1
Guide: SIP Connectivity
# Keeps your PBX or SBC: every call is sent to it over SIP TLS without
# being answered first, so the caller hears the ringing tone. If the PBX
# is unreachable, busy or does not answer, the call fails over to a mobile,
# then to a voicemail that is transcribed and emailed to you.
#
# What to edit: the variables below the MODIFIABLE VARIABLES line.
# Needs: a number assigned to this scenario, a PBX or SBC reachable over
# SIP TLS (port 5061) and an email address.
# Your PBX: allow Callr's SIP signalling and RTP ranges (eu.sip.callr.com
# or usa.sip.callr.com), see https://docs.callr.com/docs/ip-subnets
# The called number is sent in E.164 format with a leading + in the user
# part, e.g. sip:[email protected].
# Build Mode: calls only reach your Testing Numbers.
# Guide: https://docs.callr.com/docs/sip-connectivity
description: "Keep your PBX over SIP"
compat:
version: "2025-09-01"
defaults:
# Do not answer the call: the routing is transparent until the voicemail.
autoAnswer: false
voice: en-US-Olivia:MAI-Voice-2
language: en-US
variables:
$dial: null
$file: null # temporary file of the message
# MODIFIABLE VARIABLES BELOW
# Host and TLS port of your PBX or SBC
$pbxHost: pbx.example.com:5061
# Mobile that takes the call when the PBX does not answer
$backupMobile: "+33639980010"
# Email addresses that receive the voicemail
$emailTargets:
- [email protected]
branches:
inbound-call:
actions:
- action: dial@v2
params:
# 'clone' shows the caller's number to your PBX and to the mobile
callerId: clone
# false: the PBX first, then the mobile (failover)
# Busy, error or no answer within 15 s: the next target rings
parallel: false
targets:
# The called number is passed to your PBX for its own routing
- number: sip:${{ call.toNumber }}@${{ $pbxHost }};transport=tls
timeout: 15
- number: ${{ $backupMobile }}
timeout: 30
result: $dial
# Nobody picked up: take a message.
# For a fully transparent routing (no voicemail), delete this block.
- if: "!$dial?.ok"
then:
- action: answer@v1
- action: play@v3
params:
tts:
text: Sorry, we are not available. Please leave a message after the tone.
- action: record@v1
params:
maxLength: 120 # seconds
result: $file
- action: hangup@v1
hangup:
actions:
- if: $file
then:
# saveRecordingFile transcribes the message by default
- action: saveRecordingFile@v1
params:
file: ${{ $file }}
events:
ready:
- action: email@v1
params:
fromName: My Company
subject: New voicemail from ${{ call.fromNumber ?? '(hidden number)' }}
text: |
Your PBX and backup mobile did not answer.
New voicemail from ${{ call.fromNumber ?? '(hidden number)' }}. Duration: ${{ recording.duration }}s.
"${{ transcription_leg_a }}"
Download the file: ${{ recording.url }}
to: ${{ $emailTargets }}Updated about 2 hours ago
