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
| Key | Required | Default | Meaning |
|---|---|---|---|
prompt | yes | — | Played first, interruptible by the first key. |
options | yes* | — | Key → destination. |
timeout | no | 5s first digit, 3s between | How long to wait for a key. |
repeat | no | 3 | Attempts before giving up. |
invalidFile | no | — | Played after a wrong key. |
timeoutFile | no | — | Played after silence. |
onInvalid | no | — | Where a caller who kept pressing wrong keys goes. |
onTimeout | no | — | Where a caller who stayed silent goes. |
directDial | no | false | Let a caller dial an extension that is not on the menu. |
name | no | — | Label 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 0–9, *, # and A–D, 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/onTimeoutis 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:
| Type | Works | Behaviour |
|---|---|---|
extension | yes | Jumps to that plan of the same tenant and does not come back. |
ring_group | yes | Rings the group; a bridged call ends the plan. |
external | yes | Dials the number through the outbound routes. |
hangup | yes | Ends the call. |
queue | no | See below. |
ivr | no | See below. |
voicemail | no | See below. |
queue, ivr and voicemail options are accepted but do not runValidation 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.