T
Telephony SOPKnowledge Base
Search
← All topics

SWML IVR — Prompt, Gather, DTMF, and Speech Input

runnable

Build classic IVR menus in SWML using `prompt` and `play` + `cond`. Covers DTMF capture, speech input, terminators, no-input handling, branching to other sections, and loop guards.

signalwireswmlivrpromptdtmfgather
Agent trigger phrases: build IVR SWML · DTMF capture SWML · gather input speech · prompt verb SWML · press 1 for sales · no input fallback · IVR loop guard

SWML IVR — Prompt, Gather, and Branching

For classic IVR menus (press 1, press 2, speak your option), SWML uses the prompt verb to play audio or TTS and capture DTMF or speech, plus cond to branch on the result.

Basic DTMF menu

version: 1.0.0
sections:
  main:
    - answer: {}
    - prompt:
        play: "say:Press 1 for sales. Press 2 for support. Press 3 for billing."
        max_digits: 1
        terminators: "#"
        timeout: 5
    - cond:
        if: "%{prompt_value} == '1'"
        then:
          - goto: { section: sales }
        elif: "%{prompt_value} == '2'"
        then:
          - goto: { section: support }
        elif: "%{prompt_value} == '3'"
        then:
          - goto: { section: billing }
        else:
          - play:
              url: "say:Invalid choice. Goodbye."
          - hangup: {}
  sales:
    - connect: { to: "+15555550101" }
  support:
    - connect: { to: "+15555550102" }
  billing:
    - connect: { to: "+15555550103" }

Prompt parameters

| Parameter | Notes | |---|---| | play | TTS via say: prefix, or hosted audio URL. | | max_digits | Max digits to capture. Captures stop at this length. | | terminators | DTMF keys that end input early (typically "#"). | | timeout | Seconds of silence before giving up. Default 5. | | digit_timeout | Seconds between digits. Default 5. | | speech | Object — enables ASR. See below. |

The captured value lands in %{prompt_value}.

Speech input (instead of DTMF)

- prompt:
    play: "say:Are you calling about sales, support, or billing?"
    speech:
      lang: en-US
      end_silence_timeout: 1.2
      speech_timeout: 8
      hints:
        - sales
        - support
        - billing
        - account

hints is critical — ASR uses them to bias matching. Without hints, "billing" might come through as "Billy" or "build".

Speech result also lands in %{prompt_value}. Use case-insensitive matching in cond:

- cond:
    if: "%{prompt_value.toLowerCase()} == 'sales'"
    then:
      - goto: { section: sales }

Mixed input — accept DTMF or speech

- prompt:
    play: "say:Press or say 1 for sales, 2 for support."
    max_digits: 1
    speech:
      lang: en-US
      hints: [sales, support, one, two]

The first input source (DTMF or speech) that completes wins. prompt_value will be "1" or "sales".

No-input handling

prompt returns to the next verb on timeout with prompt_value empty ("").

- prompt:
    play: "say:How can I help you today?"
    timeout: 6
    speech:
      lang: en-US
- cond:
    if: "%{prompt_value} == ''"
    then:
      - play: { url: "say:I didn't hear anything. Goodbye." }
      - hangup: {}
    else:
      - goto: { section: route }

Loop guard — re-prompt up to N times

version: 1.0.0
sections:
  main:
    - answer: {}
    - goto: { section: menu }
  menu:
    - prompt:
        play: "say:Press 1 for sales or 2 for support."
        max_digits: 1
        timeout: 6
    - cond:
        if: "%{prompt_value} == '1'"
        then: [ { goto: { section: sales } } ]
        elif: "%{prompt_value} == '2'"
        then: [ { goto: { section: support } } ]
        else:
          - cond:
              if: "%{vars.attempts} >= 2"
              then:
                - play: { url: "say:Too many invalid attempts. Goodbye." }
                - hangup: {}
              else:
                - set: { vars.attempts: "%{vars.attempts || 0} + 1" }
                - goto: { section: menu }
  sales:
    - connect: { to: "+15555550101" }
  support:
    - connect: { to: "+15555550102" }

Multi-step IVR (sub-menu)

Use sections aggressively. Don't try to do everything inside main.

version: 1.0.0
sections:
  main:
    - answer: {}
    - goto: { section: main_menu }
  main_menu:
    - prompt:
        play: "say:Press 1 for billing, 2 for technical support."
        max_digits: 1
    - cond:
        if: "%{prompt_value} == '1'"
        then: [ { goto: { section: billing_menu } } ]
        elif: "%{prompt_value} == '2'"
        then: [ { goto: { section: support_menu } } ]
  billing_menu:
    - prompt:
        play: "say:Press 1 for payments, 2 for refunds."
        max_digits: 1
    - cond:
        if: "%{prompt_value} == '1'"
        then: [ { connect: { to: "+15555550111" } } ]
        elif: "%{prompt_value} == '2'"
        then: [ { connect: { to: "+15555550112" } } ]
  support_menu:
    - prompt:
        play: "say:Press 1 for new ticket, 2 for existing ticket."
        max_digits: 1
    - cond:
        if: "%{prompt_value} == '1'"
        then: [ { connect: { to: "+15555550121" } } ]
        elif: "%{prompt_value} == '2'"
        then: [ { connect: { to: "+15555550122" } } ]

When to use AI instead

Hand-rolled IVR works well when:

  • Inputs are strictly enumerable (press 1-9).
  • You need DTMF-only routing for legacy phones.
  • The flow is small and stable.

Switch to the ai verb when:

  • Callers describe needs in natural language.
  • The menu would have 5+ branches.
  • You want to capture caller intent for analytics.
  • You need to fall back to a human only when AI can't resolve.

For visual IVR construction without writing SWML, use the Call Flow Builder's Gather Input node.

Anti-patterns

  • Setting timeout to 0 — input is never gathered, flow falls through immediately.
  • Forgetting terminators on long inputs — caller has to wait for digit_timeout to expire.
  • ASR without hints — recognition accuracy drops sharply on domain-specific terms.
  • Looping a no-input menu without a counter — caller stuck forever.
  • Stuffing branches into main with deep nested cond — split into sections.

See also