{"slug":"swml-ivr-and-gather","title":"SWML IVR — Prompt, Gather, DTMF, and Speech Input","tags":["signalwire","swml","ivr","prompt","dtmf","gather"],"agent_summary":"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.","trigger_phrases":["build IVR SWML","DTMF capture SWML","gather input speech","prompt verb SWML","press 1 for sales","no input fallback","IVR loop guard"],"runnable":true,"markdown":"\n# SWML IVR — Prompt, Gather, and Branching\n\nFor 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.\n\n## Basic DTMF menu\n\n```yaml\nversion: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - prompt:\n        play: \"say:Press 1 for sales. Press 2 for support. Press 3 for billing.\"\n        max_digits: 1\n        terminators: \"#\"\n        timeout: 5\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then:\n          - goto: { section: sales }\n        elif: \"%{prompt_value} == '2'\"\n        then:\n          - goto: { section: support }\n        elif: \"%{prompt_value} == '3'\"\n        then:\n          - goto: { section: billing }\n        else:\n          - play:\n              url: \"say:Invalid choice. Goodbye.\"\n          - hangup: {}\n  sales:\n    - connect: { to: \"+15555550101\" }\n  support:\n    - connect: { to: \"+15555550102\" }\n  billing:\n    - connect: { to: \"+15555550103\" }\n```\n\n## Prompt parameters\n\n| Parameter | Notes |\n|---|---|\n| `play` | TTS via `say:` prefix, or hosted audio URL. |\n| `max_digits` | Max digits to capture. Captures stop at this length. |\n| `terminators` | DTMF keys that end input early (typically `\"#\"`). |\n| `timeout` | Seconds of silence before giving up. Default 5. |\n| `digit_timeout` | Seconds between digits. Default 5. |\n| `speech` | Object — enables ASR. See below. |\n\nThe captured value lands in `%{prompt_value}`.\n\n## Speech input (instead of DTMF)\n\n```yaml\n- prompt:\n    play: \"say:Are you calling about sales, support, or billing?\"\n    speech:\n      lang: en-US\n      end_silence_timeout: 1.2\n      speech_timeout: 8\n      hints:\n        - sales\n        - support\n        - billing\n        - account\n```\n\n`hints` is critical — ASR uses them to bias matching. Without hints, \"billing\" might come through as \"Billy\" or \"build\".\n\nSpeech result also lands in `%{prompt_value}`. Use case-insensitive matching in `cond`:\n\n```yaml\n- cond:\n    if: \"%{prompt_value.toLowerCase()} == 'sales'\"\n    then:\n      - goto: { section: sales }\n```\n\n## Mixed input — accept DTMF or speech\n\n```yaml\n- prompt:\n    play: \"say:Press or say 1 for sales, 2 for support.\"\n    max_digits: 1\n    speech:\n      lang: en-US\n      hints: [sales, support, one, two]\n```\n\nThe first input source (DTMF or speech) that completes wins. `prompt_value` will be `\"1\"` or `\"sales\"`.\n\n## No-input handling\n\n`prompt` returns to the next verb on timeout with `prompt_value` empty (`\"\"`).\n\n```yaml\n- prompt:\n    play: \"say:How can I help you today?\"\n    timeout: 6\n    speech:\n      lang: en-US\n- cond:\n    if: \"%{prompt_value} == ''\"\n    then:\n      - play: { url: \"say:I didn't hear anything. Goodbye.\" }\n      - hangup: {}\n    else:\n      - goto: { section: route }\n```\n\n## Loop guard — re-prompt up to N times\n\n```yaml\nversion: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - goto: { section: menu }\n  menu:\n    - prompt:\n        play: \"say:Press 1 for sales or 2 for support.\"\n        max_digits: 1\n        timeout: 6\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { goto: { section: sales } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { goto: { section: support } } ]\n        else:\n          - cond:\n              if: \"%{vars.attempts} >= 2\"\n              then:\n                - play: { url: \"say:Too many invalid attempts. Goodbye.\" }\n                - hangup: {}\n              else:\n                - set: { vars.attempts: \"%{vars.attempts || 0} + 1\" }\n                - goto: { section: menu }\n  sales:\n    - connect: { to: \"+15555550101\" }\n  support:\n    - connect: { to: \"+15555550102\" }\n```\n\n## Multi-step IVR (sub-menu)\n\nUse sections aggressively. Don't try to do everything inside `main`.\n\n```yaml\nversion: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - goto: { section: main_menu }\n  main_menu:\n    - prompt:\n        play: \"say:Press 1 for billing, 2 for technical support.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { goto: { section: billing_menu } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { goto: { section: support_menu } } ]\n  billing_menu:\n    - prompt:\n        play: \"say:Press 1 for payments, 2 for refunds.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { connect: { to: \"+15555550111\" } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { connect: { to: \"+15555550112\" } } ]\n  support_menu:\n    - prompt:\n        play: \"say:Press 1 for new ticket, 2 for existing ticket.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { connect: { to: \"+15555550121\" } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { connect: { to: \"+15555550122\" } } ]\n```\n\n## When to use AI instead\n\nHand-rolled IVR works well when:\n\n- Inputs are strictly enumerable (press 1-9).\n- You need DTMF-only routing for legacy phones.\n- The flow is small and stable.\n\nSwitch to the [`ai` verb](/topic/swml-ai-verb) when:\n\n- Callers describe needs in natural language.\n- The menu would have 5+ branches.\n- You want to capture caller intent for analytics.\n- You need to fall back to a human only when AI can't resolve.\n\nFor visual IVR construction without writing SWML, use the [Call Flow Builder](/topic/signalwire-call-flow-builder)'s Gather Input node.\n\n## Anti-patterns\n\n- Setting `timeout` to 0 — input is never gathered, flow falls through immediately.\n- Forgetting `terminators` on long inputs — caller has to wait for `digit_timeout` to expire.\n- ASR without `hints` — recognition accuracy drops sharply on domain-specific terms.\n- Looping a no-input menu without a counter — caller stuck forever.\n- Stuffing branches into `main` with deep nested `cond` — split into sections.\n\n## See also\n\n- [SWML overview](/topic/swml-overview)\n- [SWML AI verb](/topic/swml-ai-verb)\n- [SWML connect verb](/topic/swml-connect-verb)\n- [Call Flow Builder Gather Input node](/topic/signalwire-call-flow-builder)\n","html":"<h1>SWML IVR — Prompt, Gather, and Branching</h1>\n<p>For classic IVR menus (press 1, press 2, speak your option), SWML uses the <code>prompt</code> verb to play audio or TTS and capture DTMF or speech, plus <code>cond</code> to branch on the result.</p>\n<h2>Basic DTMF menu</h2>\n<pre><code class=\"language-yaml\">version: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - prompt:\n        play: \"say:Press 1 for sales. Press 2 for support. Press 3 for billing.\"\n        max_digits: 1\n        terminators: \"#\"\n        timeout: 5\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then:\n          - goto: { section: sales }\n        elif: \"%{prompt_value} == '2'\"\n        then:\n          - goto: { section: support }\n        elif: \"%{prompt_value} == '3'\"\n        then:\n          - goto: { section: billing }\n        else:\n          - play:\n              url: \"say:Invalid choice. Goodbye.\"\n          - hangup: {}\n  sales:\n    - connect: { to: \"+15555550101\" }\n  support:\n    - connect: { to: \"+15555550102\" }\n  billing:\n    - connect: { to: \"+15555550103\" }\n</code></pre>\n<h2>Prompt parameters</h2>\n<p>| Parameter | Notes |\n|---|---|\n| <code>play</code> | TTS via <code>say:</code> prefix, or hosted audio URL. |\n| <code>max_digits</code> | Max digits to capture. Captures stop at this length. |\n| <code>terminators</code> | DTMF keys that end input early (typically <code>\"#\"</code>). |\n| <code>timeout</code> | Seconds of silence before giving up. Default 5. |\n| <code>digit_timeout</code> | Seconds between digits. Default 5. |\n| <code>speech</code> | Object — enables ASR. See below. |</p>\n<p>The captured value lands in <code>%{prompt_value}</code>.</p>\n<h2>Speech input (instead of DTMF)</h2>\n<pre><code class=\"language-yaml\">- prompt:\n    play: \"say:Are you calling about sales, support, or billing?\"\n    speech:\n      lang: en-US\n      end_silence_timeout: 1.2\n      speech_timeout: 8\n      hints:\n        - sales\n        - support\n        - billing\n        - account\n</code></pre>\n<p><code>hints</code> is critical — ASR uses them to bias matching. Without hints, \"billing\" might come through as \"Billy\" or \"build\".</p>\n<p>Speech result also lands in <code>%{prompt_value}</code>. Use case-insensitive matching in <code>cond</code>:</p>\n<pre><code class=\"language-yaml\">- cond:\n    if: \"%{prompt_value.toLowerCase()} == 'sales'\"\n    then:\n      - goto: { section: sales }\n</code></pre>\n<h2>Mixed input — accept DTMF or speech</h2>\n<pre><code class=\"language-yaml\">- prompt:\n    play: \"say:Press or say 1 for sales, 2 for support.\"\n    max_digits: 1\n    speech:\n      lang: en-US\n      hints: [sales, support, one, two]\n</code></pre>\n<p>The first input source (DTMF or speech) that completes wins. <code>prompt_value</code> will be <code>\"1\"</code> or <code>\"sales\"</code>.</p>\n<h2>No-input handling</h2>\n<p><code>prompt</code> returns to the next verb on timeout with <code>prompt_value</code> empty (<code>\"\"</code>).</p>\n<pre><code class=\"language-yaml\">- prompt:\n    play: \"say:How can I help you today?\"\n    timeout: 6\n    speech:\n      lang: en-US\n- cond:\n    if: \"%{prompt_value} == ''\"\n    then:\n      - play: { url: \"say:I didn't hear anything. Goodbye.\" }\n      - hangup: {}\n    else:\n      - goto: { section: route }\n</code></pre>\n<h2>Loop guard — re-prompt up to N times</h2>\n<pre><code class=\"language-yaml\">version: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - goto: { section: menu }\n  menu:\n    - prompt:\n        play: \"say:Press 1 for sales or 2 for support.\"\n        max_digits: 1\n        timeout: 6\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { goto: { section: sales } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { goto: { section: support } } ]\n        else:\n          - cond:\n              if: \"%{vars.attempts} >= 2\"\n              then:\n                - play: { url: \"say:Too many invalid attempts. Goodbye.\" }\n                - hangup: {}\n              else:\n                - set: { vars.attempts: \"%{vars.attempts || 0} + 1\" }\n                - goto: { section: menu }\n  sales:\n    - connect: { to: \"+15555550101\" }\n  support:\n    - connect: { to: \"+15555550102\" }\n</code></pre>\n<h2>Multi-step IVR (sub-menu)</h2>\n<p>Use sections aggressively. Don't try to do everything inside <code>main</code>.</p>\n<pre><code class=\"language-yaml\">version: 1.0.0\nsections:\n  main:\n    - answer: {}\n    - goto: { section: main_menu }\n  main_menu:\n    - prompt:\n        play: \"say:Press 1 for billing, 2 for technical support.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { goto: { section: billing_menu } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { goto: { section: support_menu } } ]\n  billing_menu:\n    - prompt:\n        play: \"say:Press 1 for payments, 2 for refunds.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { connect: { to: \"+15555550111\" } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { connect: { to: \"+15555550112\" } } ]\n  support_menu:\n    - prompt:\n        play: \"say:Press 1 for new ticket, 2 for existing ticket.\"\n        max_digits: 1\n    - cond:\n        if: \"%{prompt_value} == '1'\"\n        then: [ { connect: { to: \"+15555550121\" } } ]\n        elif: \"%{prompt_value} == '2'\"\n        then: [ { connect: { to: \"+15555550122\" } } ]\n</code></pre>\n<h2>When to use AI instead</h2>\n<p>Hand-rolled IVR works well when:</p>\n<ul>\n<li>Inputs are strictly enumerable (press 1-9).</li>\n<li>You need DTMF-only routing for legacy phones.</li>\n<li>The flow is small and stable.</li>\n</ul>\n<p>Switch to the <a href=\"/topic/swml-ai-verb\"><code>ai</code> verb</a> when:</p>\n<ul>\n<li>Callers describe needs in natural language.</li>\n<li>The menu would have 5+ branches.</li>\n<li>You want to capture caller intent for analytics.</li>\n<li>You need to fall back to a human only when AI can't resolve.</li>\n</ul>\n<p>For visual IVR construction without writing SWML, use the <a href=\"/topic/signalwire-call-flow-builder\">Call Flow Builder</a>'s Gather Input node.</p>\n<h2>Anti-patterns</h2>\n<ul>\n<li>Setting <code>timeout</code> to 0 — input is never gathered, flow falls through immediately.</li>\n<li>Forgetting <code>terminators</code> on long inputs — caller has to wait for <code>digit_timeout</code> to expire.</li>\n<li>ASR without <code>hints</code> — recognition accuracy drops sharply on domain-specific terms.</li>\n<li>Looping a no-input menu without a counter — caller stuck forever.</li>\n<li>Stuffing branches into <code>main</code> with deep nested <code>cond</code> — split into sections.</li>\n</ul>\n<h2>See also</h2>\n<ul>\n<li><a href=\"/topic/swml-overview\">SWML overview</a></li>\n<li><a href=\"/topic/swml-ai-verb\">SWML AI verb</a></li>\n<li><a href=\"/topic/swml-connect-verb\">SWML connect verb</a></li>\n<li><a href=\"/topic/signalwire-call-flow-builder\">Call Flow Builder Gather Input node</a></li>\n</ul>\n"}