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

  1. Someone calls your number and hears the ringing tone.
  2. Your two mobiles and your PBX ring at the same time, showing the caller's number.
  3. The first one to pick up gets the call; the others stop ringing.
  4. 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@v1

Simple 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

  1. Someone calls your number and hears a short welcome menu.
  2. They press 1 to reach sales, 2 to reach support, or 3 to hear your opening hours.
  3. If they press nothing or a wrong key, the menu is played once more, then the call goes to sales.
  4. 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@v1

Speech 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

  1. When someone calls, a menu asks them to press 1 or say agent, or press 2 or say ticket.
  2. Choosing agent transfers the call to your agent's phone.
  3. Choosing ticket asks for the ticket number on the keypad and reads it back for confirmation.
  4. Your API is asked for the ticket status, which is read to the caller.
  5. 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-call

Route 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

  1. When someone calls, the scenario checks the local time in your time zone against your weekly hours and holiday list.
  2. During opening hours, all your team phones ring at the same time and the first to answer takes the call.
  3. After hours or on a holiday, the caller hears your closed message instead.
  4. If you are closed or nobody picks up, the caller leaves a voicemail.
  5. 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

  1. When someone calls, they are asked to say their name and the reason for their call.
  2. Your phone rings and you hear their recording before the caller is connected.
  3. Press 1 or say yes to take the call, or press 2 to hear the recording again.
  4. Numbers on your VIP list skip the recording and are connected right away.
  5. 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@v1

Route 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

  1. When someone calls, they hear your welcome and are asked, in a few words, why they are calling.
  2. AI reads what they said and picks sales, support or billing, while the caller hears a short waiting sound.
  3. If the caller says nothing, the question is asked once more, then a keypad menu (1, 2, 3) is offered.
  4. The caller is told which team they are being connected to and the call rings that team.
  5. 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@v1

Greet 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

  1. Someone calls your number.
  2. Their country decides the language: French, Spanish, or English for everyone else.
  3. They hear a greeting in their language, read by a native voice.
  4. The call is forwarded to the team for that language.
  5. 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@v1

Keep 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

  1. Someone calls your number and hears the ringing tone; the call is not answered yet.
  2. The call is sent to your PBX or SBC over secure SIP, with the caller's number and the number they dialed.
  3. If your PBX does not pick up within 15 seconds, your backup mobile rings for 30 seconds.
  4. If nobody answers, the caller is invited to leave a message.
  5. 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 }}

Did this page help you?