Skip to main content

IVR menus

A menu plays a prompt, collects a key and sends the call somewhere. It is a document of its own rather than part of a step, so several dialplans can share one and the flat step schema does not have to grow a nested digit map.

configVersion: lyno/v1
kind: IVRMenu
metadata:
name: main
tenant: acme
spec:
prompt: ../sounds/menu-main.wav
timeout: 5s
repeat: 3
# Options stay a map: a keypad has no order beyond which key was pressed.
options:
"1": {type: extension, target: "200"}
"2": {type: ring_group, target: sales}
"9": {type: external, target: "+31201234567"}
"0": {type: hangup}
invalidFile: ../sounds/menu-invalid.wav
timeoutFile: ../sounds/menu-timeout.wav
onTimeout: {type: extension, target: "200"}
directDial: true

A dialplan reaches it with one step:

configVersion: lyno/v1
kind: Extension
metadata:
name: "700"
tenant: acme
spec:
description: Main menu
steps:
- id: menu
type: ivr
menu: main
KeyRequiredDefaultMeaning
promptyesPlayed first, interruptible by the first key.
optionsyes*Key → destination.
timeoutno5s first digit, 3s betweenHow long to wait for a key.
repeatno3Attempts before giving up.
invalidFilenoPlayed after a wrong key.
timeoutFilenoPlayed after silence.
onInvalidnoWhere a caller who kept pressing wrong keys goes.
onTimeoutnoWhere a caller who stayed silent goes.
directDialnofalseLet a caller dial an extension that is not on the menu.
namenoLabel for the editor.

* A menu with no options and directDial: false is a validation error — no key would do anything.

Keys can be more than one digit

Valid keys are 09, *, # and AD, and a key may be several characters:

options:
"1": {type: extension, target: "200"}
"12": {type: extension, target: "201"}

Collection therefore runs to the length of the longest key before anything is matched, and matching is longest-first — otherwise 1 would answer for a caller who is still typing 12.

A key that is not on the menu counts as a wrong key, not as silence. That is what keeps invalidFile and onInvalid reachable on an ordinary single-digit menu.

Direct dial

With directDial: true a caller who knows an extension number can dial it even though it is not on the menu. The collection then has no fixed length and # ends the entry early — unless the menu spends # on an option of its own, because a configured key must win over a convenience.

Direct dial reaches a dialplan of the same tenant, or an account extension. Where it can go is unknowable at startup, so it is bounded at run time instead: one call may enter at most 20 dialplans before it is stopped.

What happens when nobody presses anything useful

Each attempt plays the prompt and waits. A wrong key plays invalidFile, a timeout plays timeoutFile, and the next attempt starts. After repeat attempts:

  • if onInvalid / onTimeout is set, the call goes there — which one is chosen depends on how the last attempt failed;
  • otherwise the menu falls through to the next step of the plan.

That is the same contract as a dial nobody answered, and it is what makes "menu, then voicemail" work with no special case:

kind: Extension
metadata: { name: "700", tenant: acme }
spec:
steps:
- {id: menu, type: ivr, menu: main}
- {id: box, type: voicemail, mailbox: "100"}

Destination types

An option, onInvalid and onTimeout all take a {type, target} destination. Four of the seven types work:

TypeWorksBehaviour
extensionyesJumps to that plan of the same tenant and does not come back.
ring_groupyesRings the group; a bridged call ends the plan.
externalyesDials the number through the outbound routes.
hangupyesEnds the call.
queuenoSee below.
ivrnoSee below.
voicemailnoSee below.
queue, ivr and voicemail options are accepted but do not run

Validation checks them, the editor offers them, and at run time the menu reports

ivr main: destination type "queue" is not wired up yet

The step then fails, and the engine's "a failing step does not kill the plan" rule sends the call on to the next step, not to the queue.

Point the key at an extension whose plan starts with the queue, ivr or voicemail step instead. That is better configuration anyway: the number gets time conditions and a fallback as well.

# instead of: "3": {type: queue, target: support}
options:
"3": {type: extension, target: "300"} # extension 300 starts with a queue step

Loops are refused at startup

Every extension destination a menu can jump to is checked when the PBX starts: a plan that points at itself, or a ring of plans pointing at each other, refuses to load rather than spinning on the first call that reaches it.

tenant acme: dialplan loop 700 → 800 → 700

Direct dial cannot be checked that way, which is why the 20-plan hop limit exists as well.

Audio

prompt, invalidFile and timeoutFile are validated and cached in memory at startup like every other sound, so a file that cannot be decoded stops the process rather than a caller's menu. They must be 8 kHz mono 16-bit PCM WAV.

Reference

Key-by-key schema: the IVRMenu document.