The IVR builder isn't a separate engine; it's the automation engine with a focused set of action types. An IVR flow is an automation record whose steps use the ivr_* actions. The same trigger system, condition system, and execution engine that drive marketing automations drive call flows — which means you can mix IVR steps with send_email, create_task, or add_tag in the same flow.
What gets built
A complete phone-routing setup involves three layers:
- Routing record — per phone number. Decides which mode runs the call:
forward|ai-assistant|simple_menu|automation_flow. Defines forwarding groups, business hours, holidays. - IVR flow — the automation, when
routingType: 'automation_flow'. Holds the step graph. - Steps — typed
ivr_*actions chained bynextStepId.
For simpler routing modes (forward, ai-assistant, simple_menu) you don't author a flow at all — the orchestrator generates TwiML from the routing record directly.
Available IVR actions
| Action | Purpose |
|---|---|
ivr_speak | Text-to-speech message |
ivr_collect_input | Collect DTMF digits or speech |
ivr_menu | Present DTMF menu options |
ivr_transfer | Transfer to phone, agent, or queue |
ivr_forward | Ring group: simultaneous or sequential |
ivr_record | Record caller audio |
ivr_voicemail | Voicemail with transcription + notifications |
ivr_play_audio | Play an audio file by URL |
ivr_send_sms | Send SMS during the call |
ivr_send_email | Send email during the call |
ivr_ai_assistant | Hand off to the AI voice assistant via <Stream> |
ivr_twilio_pay | PCI-compliant payment collection |
ivr_hangup | End the call |
Source: appengine/src/automation/actions/ivr/. Each action validates its config, generates TwiML, and (where it waits for caller input) sets pauseExecution: true so the orchestrator parks the run until Twilio calls back.
Variable interpolation
Every action supports {{variable}} substitution against the call context:
{{caller}}— caller's E.164 number{{calledNumber}}— the IVR number that was dialed{{callSid}}— Twilio's call ID{{variables.<name>}}— anything stored viasaveAsfrom a previous step
So Thanks for choosing {{variables.selectedDept}} reads back what the menu step captured.
Flow JSON shape
A flow is an automation record. The IVR-relevant fields:
{
"name": "Sales support menu",
"trigger": { "type": "ivr-incoming-call" },
"steps": [
{
"id": "greet",
"type": "ivr-action",
"actionType": "ivr_speak",
"config": {
"message": "Welcome to Acme. Please listen carefully.",
"voice": "Polly.Joanna-Neural"
},
"nextStepId": "menu"
},
{
"id": "menu",
"type": "ivr-action",
"actionType": "ivr_menu",
"config": {
"greeting": "For sales press 1, for support press 2, or stay on the line.",
"saveAs": "selection",
"options": [
{ "digit": "1", "label": "sales", "nextStepId": "transfer-sales" },
{ "digit": "2", "label": "support", "nextStepId": "transfer-support" }
],
"onTimeout": "voicemail"
}
},
{
"id": "transfer-sales",
"type": "ivr-action",
"actionType": "ivr_transfer",
"config": {
"transferType": "phone",
"to": "+14155550100",
"record": true
}
},
{
"id": "transfer-support",
"type": "ivr-action",
"actionType": "ivr_forward",
"config": {
"ringMode": "simultaneous",
"ringDuration": 25,
"targets": [{ "type": "phone", "value": "+14155550101" }],
"fallback": "voicemail"
}
},
{
"id": "voicemail",
"type": "ivr-action",
"actionType": "ivr_voicemail",
"config": {
"greeting": "Sorry we missed you. Leave a message after the tone.",
"maxLength": 180,
"transcribe": true,
"notifyEmail": "[email protected]"
}
}
]
}
The id values are referenced by nextStepId (and by menu options' per-option nextStepId). Branching is implicit in those references.
Authoring through the API
Flows are created the same way as any automation:
/automationJWTcurl -X POST https://appengine.appmint.io/automation \
-H "orgid: my-org" -H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{ "data": { "name": "...", "trigger": {...}, "steps": [...] } }'
Update with PUT /automation/:automationId, delete with DELETE /automation/:automationId.
Wiring a flow to a number
Once the flow exists, attach it to a phone routing record:
{
"phoneNumber": "+14155551234",
"routingType": "automation_flow",
"normalHoursFlowId": "<automationId>",
"afterHoursFlowId": "<automationId>",
"businessHours": { "enabled": true, "officeHours": [...], "holidays": [...] }
}
After-hours and holiday flows are optional. If unset, the normal-hours flow runs around the clock.
Testing without a phone
The action layer is testable in isolation:
/automation/{automationId}/executeJWTPass synthetic variables in the body and the orchestrator runs the flow as if a call had come in. The TwiML is returned in the step results so you can eyeball what Twilio would have rendered.
Pause / resume mechanics
Most IVR actions are synchronous: speak, hangup, send-sms. A few — ivr_collect_input, ivr_menu, ivr_record, ivr_voicemail, ivr_twilio_pay — set pauseExecution: true and emit TwiML with an action URL pointing at:
/connect/webhook/twilio/ivr-{input|menu-input|recording|payment-status}?executionId={id}&saveAs={var}&nextStepId={id}
Twilio calls that endpoint with the user's input. The webhook stores the captured value (under saveAs), looks up the flow execution, and resumes from nextStepId — generating the next TwiML in the response.
Recommended structure
For anything beyond a 3-step happy path, organize the flow as a directed graph:
- One greeting /
ivr_speak - One
ivr_menuper branch point with explicitonTimeoutfallback - Leaf actions:
ivr_transfer,ivr_forward,ivr_voicemail,ivr_hangup - Reusable error path that ends in
ivr_voicemailso callers always have an exit
Flows that don't terminate (no ivr_hangup and no transfer) leave Twilio holding an open call until its 4-hour cap. Always make sure every leaf is a terminal action.
The IVR builder UI in the Appmint Studio admin is a graph editor on top of this same JSON. It surfaces validation errors from each action's validate() so configuration mistakes are caught before publishing.