Build a phone assistant (IVR)
Build an interactive voice response (IVR) phone menu: the caller hears a menu, presses a key or speaks, and the workflow either reads a static answer or has an LLM answer the spoken question. It is an API workflow: Twilio posts each call step to a route, and each route answers with TwiML built by the twiml component.
Needs: a Twilio account and phone number for a live call; curl is enough to test.
The finished project is examples/telephony-bot.
Step 1: define the call routes
# workflow.yaml
apiVersion: kdeps.io/v1
kind: Workflow
metadata:
name: telephony-bot
description: AI phone IVR - Twilio webhooks answered with TwiML from the twiml component
version: "1.0.0"
targetActionId: respond
settings:
apiServer:
hostIp: "127.0.0.1"
portNum: 16395
routes:
- path: /twilio/voice # entry point: welcome menu
methods: [POST]
public: true # Twilio sends no bearer token
- path: /twilio/menu # the key the caller pressed
methods: [POST]
public: true
- path: /twilio/hours # static answer: opening hours
methods: [POST]
public: true
- path: /twilio/ask # gather a spoken question
methods: [POST]
public: true
- path: /twilio/answer # LLM answers the spoken question
methods: [POST]
public: true
agentSettings:
timezone: Etc/UTCStep 2: the welcome menu
A gather collects one key. Twilio posts it to /twilio/menu as Digits; with no key, the redirect replays the menu.
# resources/welcome.yaml
actionId: welcome
name: Welcome Menu
validations:
routes: [/twilio/voice]
component:
name: twiml
with:
verbs:
- gather:
input: dtmf
numDigits: 1
timeout: 8
action: /twilio/menu # Twilio posts the pressed key here as Digits
say: "Welcome to kdeps. Press 1 for opening hours. Press 2 to ask our assistant a question."
- redirect: /twilio/voice # no key pressed: play the menu againStep 3: route the key
The pressed key picks the next route; anything else goes back to the menu.
# resources/menu.yaml
actionId: menuChoice
name: Menu Choice
validations:
routes: [/twilio/menu]
component:
name: twiml
with:
verbs:
# Digits is the key from the welcome menu's gather.
- redirect: "{{ get('Digits') == '1' ? '/twilio/hours' : get('Digits') == '2' ? '/twilio/ask' : '/twilio/voice' }}"Step 4: a static answer
Speak the hours, then hang up.
# resources/hours.yaml
actionId: sayHours
name: Say Opening Hours
validations:
routes: [/twilio/hours]
component:
name: twiml
with:
verbs:
- say: {text: "We are open Monday to Friday, nine to five, Central European Time.", voice: alice}
- hangup: trueStep 5: gather a spoken question
A speech gather: Twilio transcribes the caller and posts the text to /twilio/answer as SpeechResult.
# resources/ask.yaml
actionId: askQuestion
name: Ask Question
validations:
routes: [/twilio/ask]
component:
name: twiml
with:
verbs:
- gather:
input: speech
speechTimeout: auto
action: /twilio/answer # Twilio posts the transcript here as SpeechResult
say: "What would you like to know?"Step 6: the LLM answer
The model answers the transcript in one sentence.
# resources/answer.yaml
actionId: answerLLM
name: LLM Answer
validations:
routes: [/twilio/answer]
check:
- get('SpeechResult') != '' # SpeechResult arrives in Twilio's webhook body
error:
code: 400
message: "SpeechResult is required"
chat:
model: llama3.2:1b
role: user
prompt: |
A caller asked the following question over the phone:
"{{ get('SpeechResult') }}"
Answer in one short, friendly sentence suitable for being read aloud.
timeout: 60sStep 7: speak the answer
The answer goes into say - the component XML-escapes it.
# resources/speak.yaml
actionId: speakAnswer
name: Speak Answer
requires: [answerLLM]
validations:
routes: [/twilio/answer]
component:
name: twiml
with:
verbs:
- say: "{{ get('answerLLM').message.content }}"
- hangup: trueStep 8: return the TwiML
Each request runs only the resources for its route, so exactly one twiml call has output. Content-Type: text/xml writes it as the raw body.
# resources/respond.yaml
actionId: respond
name: TwiML Response
requires: [welcome, menuChoice, sayHours, askQuestion, speakAnswer]
apiResponse:
success: true
headers:
Content-Type: text/xml # Twilio reads the body as TwiML
# Exactly one twiml call ran - the one for this request's route.
response: "{{ (output('welcome') ?? output('menuChoice') ?? output('sayHours') ?? output('askQuestion') ?? output('speakAnswer')).twiml }}"Step 9: run and test
kdeps validate workflow.yaml
export KDEPS_API_AUTH_TOKEN=dev-token # the /twilio routes are public; others need it
kdeps run workflow.yamlPost what Twilio would post:
curl -X POST http://localhost:16395/twilio/voice -d CallSid=CA1
curl -X POST http://localhost:16395/twilio/menu -d Digits=1
curl -X POST http://localhost:16395/twilio/answer -d "SpeechResult=what time do you close"<?xml version="1.0" encoding="UTF-8"?><Response><Gather action="/twilio/menu" input="dtmf" numDigits="1" timeout="8"><Say>Welcome to kdeps. Press 1 for opening hours. Press 2 to ask our assistant a question.</Say></Gather><Redirect>/twilio/voice</Redirect></Response>
<?xml version="1.0" encoding="UTF-8"?><Response><Redirect>/twilio/hours</Redirect></Response>
<?xml version="1.0" encoding="UTF-8"?><Response><Say>We close at five in the evening.</Say><Hangup /></Response>The answer's wording comes from the model and varies. For a live call, set the Twilio number's voice webhook to https://<your-host>/twilio/voice (HTTP POST).
Next steps
- twiml component - every verb and attribute
- Transcribe component - speech to text on your own audio
- Validation and control flow - route scoping
- Bot replies - chat-platform bots
