Advanced Technical Success Manager
André MartinsSignalWire's new AI Agent allows you to build innovative customer service tools into your applications with minimal coding. When creating an application with our Compatibility API, you can easily implement an AI agent for tasks such as rescheduling appointments to create your own virtual receptionist.
In this step-by-step guide, we will build an appointment reminder application with built-in AI answering machine detection and an AI-powered virtual assistant that can reschedule appointments.
We'll break down each piece of code to explain the functionality of machine detection and the integration with SignalWire’s AI virtual agent. By following this guide, you'll gain a clear understanding of how to leverage AI to take your customer experience to the next level.
Before we begin, we assume you have:
Basic knowledge of NodeJS
Basic knowledge of Git
Docker installed
A SignalWire Space with a Phone Number
Running the Application
Clone the signalwire-in-seconds repo and go to the ai-appointment-reminders folder.
Copy the .env.example file to a file named .env, and fill in the necessary information:
NGROK_TOKEN=XXXXXXXXXXXXXXXXXXXX_XXXXXXXXXXXXXXXXXXXXX | |
PORT=3000 | |
SIGNALWIRE_PROJECT_ID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX | |
SIGNALWIRE_API_TOKEN=PTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX | |
SIGNALWIRE_SPACE=YOUR_SPACE.signalwire.com | |
FROM_NUMBER=+123456789 |
Build the container by running
docker build . -t reminder |
And then start the container with
docker run -it --rm -p 3000:3000 --name reminder --env-file .env reminder |
Your application is now ready to receive POST requests to the /startReminder endpoint. It expects the following data about the patient and their appointment:
{ | |
name: ‘André’, | |
number: ‘+123456789’, | |
date: '2023-07-11', | |
time: '08:30' | |
} |
The application will then make a call to the patient’s number, and transfer it to the AI Agent. Should a machine be detected, the virtual agent will be made aware of it and leave a voicemail instead of trying to interact.
If a machine is not detected, the magic happens! The AI agent starts interacting with the patient to confirm they’ll be attending the appointment, and if not, work with the patient to reschedule. While doing so, the agent will check available time slots and update them accordingly.
Breaking down the code
If you want to jump straight to the code, see our signalwire-in-seconds repository on GitHub.
Setting up dependencies
We start by importing all of our dependencies:
require('dotenv').config(); | |
const ngrok = require('ngrok'); | |
const express = require('express'); | |
const bodyParser = require('body-parser'); | |
const { RestClient } = require('@signalwire/compatibility-api'); | |
let ngrok_url; | |
(async function () { | |
ngrok_url = await ngrok.connect({ | |
authtoken: process.env.NGROK_TOKEN, | |
addr: process.env.PORT | |
}); | |
console.log(ngrok_url); | |
})(); | |
const app = express(); | |
app.use(bodyParser.json()); | |
app.use(bodyParser.urlencoded({ extended: true })); | |
const client = new RestClient( | |
process.env.SIGNALWIRE_PROJECT_ID, | |
process.env.SIGNALWIRE_API_TOKEN, | |
{ | |
signalwireSpaceUrl: process.env.SIGNALWIRE_SPACE | |
} | |
); |
We load the .env file to get all of our credentials, import ngrok so we can have a tunnel to our machine, import Express so we can use it to expose the various routes, and finally, we import SignalWire’s RestClient so we can create or update calls and compose compatibility XML instructions so SignalWire can run them.
Mocking Time Slots
For simplicity, we use an object that uses days as keys and then has an array with the available times:
let availability = { | |
"2023-07-05": [ | |
], | |
"2023-07-10": [ | |
"11:15", | |
"14:30" | |
], | |
"2023-07-11": [ | |
"13:00" | |
] | |
} |
Ideally, you should build functions to fetch availability from your CRM or a database instead.
The /startReminder endpoint
This is where all calls to patients will start. Make a POST request to this endpoint with patient data and SignalWire will make a call that:
Gets XML Instructions from /agent
Runs Answering Machine Detection asynchronously and sends results to /amd so we can then make decisions to change the AI Agent’s behavior.
app.post('/startReminder', async (req, res) => { | |
console.log(req.body) | |
let name = req.body.name | |
let number = req.body.number | |
let date = req.body.date | |
let time = req.body.time | |
await client.calls.create({ | |
url: ngrok_url + '/agent' + `?name=${name}&` + `date=${date}&` + `time=${time}`, | |
to: number, | |
from: process.env.FROM_NUMBER, | |
machineDetection: "DetectMessageEnd", | |
machineDetectionTimeout: 45, | |
asyncAmd: true, | |
asyncAmdStatusCallback: ngrok_url + "/amd" + `?name=${name}&` + `date=${date}&` + `time=${time}`, | |
asyncAmdStatusCallbackMethod: "POST" | |
}); | |
return res.sendStatus(200) | |
}); |
The /amd endpoint
As SignalWire listens to the callee’s audio, it may determine that the call was answered by a machine. If so, instead of the call continuing to run /agent’s XML instructions, we update it so it runs /leaveVoicemail’s.
app.post('/amd', async (req, res) => { | |
console.log(req.body); | |
let name = req.query.name | |
let date = req.query.date | |
let time = req.query.time | |
switch (req.body.AnsweredBy) { | |
case "machine_end_beep": | |
case "machine_end_silence": | |
case "machine_end_other": | |
await client.calls(req.body.CallSid).update({ | |
url: ngrok_url + "/leaveVoicemail" + `?name=${name}&` + `date=${date}&` + `time=${time}` | |
}) | |
break; | |
} | |
}) |
The /leaveVoicemail endpoint
When this endpoint’s code runs, it means SignalWire detected a machine answered the phone, and it is now time to change from trying to interacting with the patient to leaving a message.
We create a new VoiceResponse to start crafting our XML instructions, and use the AI Noun to give the agent a prompt describing what it should do. Check out our AI best practices guide for more information on how to write effective prompts. Finally, we convert the instructions into a string and return them so SignalWire can run them on the call.
app.post('/leaveVoicemail', (req, res) => { | |
console.log(req.query) | |
let name = req.query.name | |
let date = req.query.date | |
let time = req.query.time | |
const response = new RestClient.LaML.VoiceResponse(); | |
const connect = response.connect(); | |
const ai = connect.ai(); | |
ai.prompt( | |
{ | |
confidence: 0.2, | |
temperature: 0, | |
bargeConfidence: 0 | |
}, | |
`You are Gordon, an assistant at Doctor Fibonacci's office. You call patients of his to confirm appointment times. In this case you're leaving ${name} (there's no need to mention their name unless it feels natural) a voicemail about their upcoming appointment on ${date}, at ${time}. | |
Tell the patient they can call back if they need to reschedule and hang up. | |
` | |
); | |
const instructions = response.toString() | |
console.log(instructions); | |
res.send(instructions) | |
}); |
The /agent endpoint
This endpoint returns the instructions for all calls as they start, and tells the AI agent it should interact with the patient to confirm their availability. While the code might look daunting due to being extensive, most of it is setting up a prompt just as we did in the /leaveVoicemail endpoint, with the end goal being to get the conversational AI to behave exactly like we want it to.
We structure the tasks we want the AI agent to carry out in steps, as this ensures the agent will honor the call flow.
app.post('/agent', (req, res) => { | |
console.log(req.query) | |
let name = req.query.name | |
let date = req.query.date | |
let time = req.query.time | |
const response = new RestClient.LaML.VoiceResponse(); | |
const connect = response.connect(); | |
const ai = connect.ai(); | |
ai.prompt( | |
{ | |
confidence: 0.2, | |
temperature: 0, | |
bargeConfidence: 0 | |
}, | |
`You are Gordon, an assistant at Doctor Fibonacci's office. You call patients of his to confirm appointment times. In this case you're calling ${name} (there's no need to mention their name unless it feels natural) about their upcoming appointment on ${date}, at ${time}. | |
# Step 1 | |
Remind the patient about their upcoming appointment. | |
# Step 2 | |
Ask if they can confirm they're going to be attending. | |
## Step 2.1 | |
If they cannot attend, ask them for a day when they'll be available. | |
## Step 2.2 | |
Use the get_available_times function to get the list of available times slots. | |
## Step 2.3 | |
Have the patient pick a time slot. | |
## Step 2.4 | |
Thank the patient for picking a new time slot, ask them to wait while you confirm the change, stop talking, and move on to the next step. | |
## Step 2.5 | |
Use the update_appointment_schedule function to update the available time slots. Never skip this step. | |
## Step 2.6 | |
End the call without offering further help. | |
` | |
); | |
const swaig = ai.swaig() | |
const defaults = swaig.defaults() | |
defaults.setWebHookURL(ngrok_url + "/functionHandler") | |
const getAvailableTimes = swaig.function() | |
getAvailableTimes.setName("get_available_times") | |
getAvailableTimes.setArgument("The date the customer is available on, in YYYY-MM-DD format.") | |
getAvailableTimes.setPurpose("To get the date available times for a particular date.") | |
const scheduleAppointment = swaig.function() | |
scheduleAppointment.setName("update_appointment_schedule") | |
scheduleAppointment.setArgument("The new date the customer is available on, in YYYY-MM-DD format. The new time the customer is available at, in HH:MM 24h format. The old date the appointment was supposed to take place on, in YYYY-MM-DD format. The old time the appointment was supposed to take place at, in HH:MM 24h format. Separate each value with commas and without spaces.") | |
scheduleAppointment.setPurpose("To update the list of available time slots once the patient agrees to reschedule.") | |
const instructions = response.toString() | |
console.log(instructions); | |
res.send(instructions) | |
}); |
Then, we set up the get_available_times and update_appointment_schedule functions inside of the SignalWire AI Gateway with the appropriate name, argument, and purpose. We can leverage SignalWire’s AI Gateway to “break out” of the AI’s constraints and interface with other systems.
The functions we define will have a default Webhook URL they send POST requests to. In this case we use the /functionHandler endpoint, and the data the functions will send to it look like this:
{ | |
app_name: 'laml app', | |
content_type: 'text/swaig', | |
version: '2.0', | |
content_disposition: 'SWAIG Function', | |
function: 'get_available_times', | |
argument: { parsed: [], raw: '2023-07-05', substituted: '2023-07-05' }, | |
call_id: 'e697030f-28f9-4158-897b-8b8b45f7618a', | |
argument_desc: 'The date the customer is available on, in YYYY-MM-DD format.', | |
purpose: 'To get the date available times for a particular date.', | |
meta_data_token: '6664a38c0d73a08dc081575a5c32594e', | |
meta_data: {} | |
} |
The most important piece of our functions is the argument, as it tells SignalWire’s AI what to send in the POST request, and exactly in what format.
The /functionHandler endpoint
As the name suggests, this endpoint handles requests from SignalWire’s AI Gateway functions, and you can adapt it based on your requirements.
If the function that was triggered is get_available_times we get the day from the argument and return the array of available times.
If the function that was triggered is update_appointment_schedule we get new and old date/time combos, and update available time slots.
app.post('/functionHandler', (req, res) => { | |
console.log(req.body); | |
switch (req.body.function) { | |
case 'get_available_times': | |
let day = req.body.argument.raw | |
if(!availability.hasOwnProperty(day)) { | |
availability[day] = [] | |
} | |
let confirmation = { | |
response: JSON.stringify(availability[day]) | |
} | |
res.send(JSON.stringify(confirmation)) | |
break; | |
case 'update_appointment_schedule': | |
let arguments = req.body.argument.raw.split(","); | |
let newDate = arguments[0] | |
let newTime = arguments[1] | |
let oldDate = arguments[2] | |
let oldTime = arguments[3] | |
console.log("Time slots before updates:", availability); | |
// Remove new appointment time from available times | |
for (let day in availability) { | |
if (availability.hasOwnProperty(newDate)) { | |
availability[newDate] = availability[newDate].filter(item => item !== newTime); | |
break; | |
} | |
} | |
console.log("Time slots after removing the new appointment time:", availability); | |
// Add old appointment time back to available times | |
for (const day in availability) { | |
if (availability.hasOwnProperty(oldDate)) { | |
availability[oldDate].push(oldTime); | |
break; | |
} | |
} | |
console.log("Time slots after adding old appointment time:", availability); | |
res.send(JSON.stringify({ | |
response: "Appointment rescheduled." | |
})) | |
break; | |
} | |
}); |
This code sets up a POST route for the /functionHandler endpoint. It extracts the functionName from the request body and handles different function names accordingly.
Congratulations! You have successfully built an appointment reminder app with answering machine detection and AI Agent integration! With this knowledge, you can customize and enhance the app to suit your specific requirements. Experiment and integrate SignalWire into your applications for seamless and efficient voice interactions by signing up for a free trial.
Explore developer.signalwire.com to learn more about SignalWire’s capabilities, and join our Community Slack and Forum to interact with the team. We can’t wait to see what you build!