Build a Fully Functional Call Center with Dynamic JSON Menus and Python

This guide demonstrates how to use SignalWire APIs to create a completely functional call center where the features are controlled by the JSON configuration file config.json making it exceedingly easy to enable/disable features in minutes! The dynamic setup of the JSON menus adds a greater level of customizability to your IVR and makes modifying the structure on the fly a breeze.

The features of this demo include all of the following:

-> TTS Voice
-> Default Entry Point
-> Agents Configurations
-> Agent Routing (default, is First_Available)
-> Multi-Channel (default, shows PSTN/Voice)
-> Accepted Channels (Determines, which channels are allowed)
-> Recording
-> Exit Survey Routing
-> Text Summary After Call
-> Announcement Message
-> Exit Message
-> Waiting Music/Message/Ads
-> Queue Stats Messaging with Template Vars
-> Agent Wrap Up Ability
-> Dynamic Menu Building and Routing
-> "Smarter" Queues Tricks to extend queues to use meta info such as skills
-> Recording Ability
-> CDR Log Handling
-> Text Bot Ability

This is a living repo that will be expanded and contributed to in order to continually improve and advance it. We also have a web-based GUI that will be built on top for additional versions in the future.

What do I need to run this code?

You will need a machine with Python installed, the SignalWire Python SDK, a provisioned SignalWire phone number, and optionally Docker if you decide to run it in a container.

If you'd like to first try out the precursor that this application was built on, check out the guide here!

Configuring the JSON file

The config.json file needs to be set up to your specifications. This example file has endpoints/groups that will contain the list of agents. Each menu contains an index that is equal to the key-press on the menu, verbiage, and an action.

You can view or fork the code in our Github Repo to view all of it, but an example is shown below! You can use the settings menu to enable/disable certain features such as waiting music, wait time advertisements, or the language/dialect for text to speech. The use of a JSON menu to specify menus, settings, and other instructions is a feature that will save you hours of development time when setting up or customizing your call center!

{
  "settings": {
    "name": "<Signalwire Call Center Name>",
    "outboundPhoneNumber": "<Signalwire Phone Number For Inbound and Outbound Interactions>",
    "hostname": "<FQDN - Fully Qualified Domain Name>",
    "menuEntryPoint": "main",
    "agentHuntMode": "First_Available",
    "textToSpeech":{
      "voice" : "Polly.Joanna-Neural"
    },
    "acceptedChannels": [
      "pstn",
      "sip"
    ],
    "enableRecording": false, 
    "enableExitSurvey": false,
    "enableCallback": false,
    "enableInboundText": false,
    "enableTextSummary": true,
    "enableAnnouncment": false,
    "enableQueueStatsMessage": true,
    "enableExitMessage": false,
    "enableExitMessageFile": true,
    "enableWaitingMusic": true,
    "enableWaitingAds": true,
    "waitingMusics": [
      "https://sinergyds.blob.core.windows.net/signalwire/8d82b5_The_Muppet_Show_Theme_Song.mp3",
      "https://sinergyds.blob.core.windows.net/signalwire/popcorn.mp3"
    ],
    "waitingAds": [
      "https://sinergyds.blob.core.windows.net/signalwire/ad_sample_1.mp3",
      "https://sinergyds.blob.core.windows.net/signalwire/ad_sample_2.mp3",
      "https://sinergyds.blob.core.windows.net/signalwire/ad_sample_3.mp3",
      "https://sinergyds.blob.core.windows.net/signalwire/ad_sample_4.mp3"
    ],
    "agents": {
      "0": {
        "name": "Kevin G.",
        "wrapup":{
          "enableAgentWrapUp": false,
          "wrapUpUrl": "/some/endpoint/to/handle/post/call/stuff",
          "wrapUpUrlMethod": "POST",
          "meta":[
            "call"
          ]
        },
        "channels": {
          "sip": {
            "endpoint": "",
            "maxInteractions": 1,
            "exclusiveInteraction": true
          },
          "pstn": {
            "endpoint": "<Your Agent Phone Number E.164 Format>",
            "maxInteractions": 1,
            "exclusiveInteraction": true
          },
          "sms": {
            "endpoint": "<Your Agent Phone Number E.164 Format>",
            "maxInteractions": 1,
            "exclusiveInteraction": false
          },
          "email": {
            "endpoint": "<Your Agent Email Address>",
            "maxInteractions": 2,
            "exclusiveInteraction": false
          },
          "video": {
            "endpoint": "",
            "maxInteractions": 1,
            "exclusiveInteraction": true
          },
          "dapp": {
            "endpoint": "",
            "maxInteractions": 1,
            "exclusiveInteraction": true
          }
        },
        "channelsEnabled": [
          "pstn"
        ],
        "roles": [],
        "skills": [
          "english",
          {
            "desirability": 1,
            "proficiency": 1
          },
          "spanish",
          {
            "desirability": 0,
            "proficiency": 0
          }
        ]
      },
      "1": {
        "name": "Rick Sanchez",
        "channels": {
          "pstn": {
            "endpoint": "<Your Agent Phone Number E.164 Format>"
          }
        },
        "wrapup":{
          "enableAgentWrapUp": false,
          "wrapUpUrl": "/some/endpoint/to/handle/post/call/stuff",
          "wrapUpUrlMethod": "POST",
          "meta":[
            "call"
          ]
        },
        "channelsEnabled": [],
        "roles": [],
        "skills": [
          "english",
          {
            "desirability": 1,
            "proficiency": 1
          },
          "spanish",
          {
            "desirability": 0,
            "proficiency": 0
          }
        ]
      }
    },

Configuring the Python code

We need to start with a little housekeeping by creating an array to store our queues, opening the config.json file so that we can access the values inside, and setting our SignalWire authentication credentials. There are a few additional functions such as getLogs(), cycle_check_thread(), and updateLogs() that we will not review in this writeup but you can read about them in detail through line comments within app.py!

# Active Queues Array
ActiveQueues = []

# read menus from json file
with open('config.json') as f:
     ccConfig = json.load(f)
     # Dump config to console, for debugging
     pprint.pprint(ccConfig)

# Set Signalwire Creds from JSON Config 
HOSTNAME = ccConfig['settings']['hostname']
SIGNALWIRE_SPACE = ccConfig['signalwire']['space']
SIGNALWIRE_PROJECT= ccConfig['signalwire']['project']
SIGNALWIRE_TOKEN= ccConfig['signalwire']['token']

Our first function to define will be enqueue() which will use the Enqueue verb to place a caller into a specified queue. We will use the friendlyName parameter to indicate the queue and specify additional parameters such as the action URL containing instructions for what to do next and the waitUrl to play music.

def enqueue(queueName, action = HOSTNAME + "/enqueue_event", method = "POST", waitUrl=HOSTNAME + "/wait_music_queue", waitUrlMethod="POST"):
    response = VoiceResponse()
    response.say("Dialing " + queueName + " one moment please...", voice=ccConfig['settings']['textToSpeech']['voice'])
    response.enqueue(queueName, action=action, method=method, waitUrl=waitUrl, waitUrlMethod=waitUrlMethod)
    print(response)
    # return response
    return response

Our next function dial_conference() will use the Dial verb and the Conference noun to connect an endpoint to a conference using the friendlyName parameter and allow to specify additional parameters such as statusCallbackEvent or startConferenceOnEnter.

def dial_conference(conferenceName, muted=False, beep=False, startConferenceOnEnter=True, endConferenceOnExit=False, statusCallbackEvent="start end join leave speaker", statusCallback=HOSTNAME + "/conference_event", statusCallbackMethod="POST"):
    response = VoiceResponse()
    #response.say("Dialing " + conferenceName + " one moment please...", voice=ccConfig['settings']['textToSpeech']['voice'])
    dial = Dial()
    dial.conference(conferenceName, muted=muted, beep=beep, startConferenceOnEnter=startConferenceOnEnter, endConferenceOnExit=endConferenceOnExit, statusCallbackEvent=statusCallbackEvent,statusCallback=statusCallback, statusCallbackMethod=statusCallbackMethod)
    response.append(dial)

    print(response)

    # return response
    return response

The next function to define is redirectByRestApi() which uses the Update Call API to redirect it using the callSid. This will merge the caller into the conference with the agent assigned to the caller.

def redirectByRestApi(callSid, urlToRedirect):
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    # merge the caller into the conference with connect_agent
    caller = client.calls(callSid) \
               .update(
                   url= HOSTNAME + urlToRedirect,
                    method='POST'
                )

    pprint.pprint(caller)
    return "200"

The next function connect_agent_ready() allows us to update a call by queue like in the example above, but you can use a callSid or say Front to pop the next member in the queue and merge the call. In some cases, you may not have a callSid! This function allows the queue to handle the update and get the next caller connected to the agent even without that parameter.

def connect_agent_ready(queueSid, callSidOrFront, agent, channel):
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    pprint.pprint(agent)

    # Agent should always connect first, or make the first request 
    # We have an agent ready, willing and able to provide stellar customer service, add agent to conference
    # We use conference, because it gives us more power for later, and advanced features.
    endpoint = agent['channels'][channel]['endpoint']
    call = client.calls.create(
                        url = HOSTNAME + '/connect_agent',
                        to = endpoint,
                        from_ = ccConfig['settings']['outboundPhoneNumber']
                    )

    pprint.pprint(call)

    # merge the caller into the conference with connect_agent
    member = client.queues(queueSid) \
               .members(callSidOrFront) \
               .update(
                   url= HOSTNAME + '/connect_caller',
                    method='POST'
                )

    print(member.call_sid)
    return "200"

Now that we have displayed all the major conference functionality functions, we will move on to the routes for handling GET or POST requests! As with the previous section, we will leave out some of the housekeeping routes for clarity but you can view the detailed comments within app.py to learn more about them.

Our first route /conference_event will accept conference events from GET or POST. Within this route we will authenticate the SignalWire Client, look for the last conference participant, and redirect them for post conference using the redirectByRestApi() function we defined in the previous section.

@app.route('/conference_event', methods=['GET', 'POST'])
def conference_event():
    
    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    
    # May optimize later, this will take the last conference participant and redirect them for post conference
    if request.values.get('StatusCallbackEvent') == 'participant-leave':
        participants = client.conferences(request.values.get('ConferenceSid')) \
                     .participants \
                     .list(limit=2)
        for record in participants:
            print(record.call_sid)
            redirectByRestApi(record.call_sid, '/post_conference?fromnumber=' + str(request.values.get('from')))
   
    pprint.pprint(request.values)

    # return response
    return "200"

The next app route /wait_music_queue will generate wait music as well as queue stat updates by utilizing our logger functions. Here we will update the queue logs and then play a message stating the caller's position in the queue, the current size of the queue, and the average queue size. If the config.json file has enableWaitingAds and enableWaitingMusic set to True in the settings menu, we will play waiting music and ads until the music and ads cycle has completed. At this point, we will redirect back to the same /wait_music_queue and increase the count by 1.

@app.route('/wait_music_queue', methods=['GET', 'POST'])
def wait_music_queue():
    count = 0
    if request.values.get('count'):
        count = int(request.values.get('count')) + 1

    response = VoiceResponse()
      
    # Update the queueStats Logs
    updateLogs('queueStats', request.values)

    # Load the queueStatsMessage from config
    queueStatsMessage = ccConfig['settings']['messages']['queueStatsMessage']
    # Replace Template Vars With Values
    queueStatsMessage = queueStatsMessage.replace("%QueuePosition%", str(request.values.get("QueuePosition")))
    queueStatsMessage = queueStatsMessage.replace("%CurrentQueueSize%", str(request.values.get("CurrentQueueSize")))
    queueStatsMessage = queueStatsMessage.replace("%AvgQueueTime%", str(request.values.get("AvgQueueTime")))

    # If enabled play queueStats to caller on hold
    response.say(queueStatsMessage, voice=ccConfig['settings']['textToSpeech']['voice'])

    if ccConfig['settings']['enableWaitingAds']:
        if count < len(ccConfig['settings']['waitingAds']):
            response.play(ccConfig['settings']['waitingAds'][count])
        else:
            count = 0
            response.play(ccConfig['settings']['waitingAds'][count])

    if ccConfig['settings']['enableWaitingMusic']:
        for music in ccConfig['settings']['waitingMusics']:
            response.play(music)

    response.redirect(HOSTNAME + "/wait_music_queue?count=" + str(count), method="POST")
    return str(response)

The next few routes are quite short and perform very simple functions! /enqueue_event and /voice_event both exist to update the logs with the correct queue stats and voice stats. The /inbound_sms route handles the acceptance of inbound messages but does not respond.

# accepts enqueue events from GET or POST
@app.route('/enqueue_event', methods=['GET', 'POST'])
def enqueue_event():
    updateLogs('queueStats', request.values)
    # return response
    return "200"

# accepts voice status events from GET or POST
@app.route('/voice_event', methods=['GET', 'POST'])
def voice_event():
    updateLogs('voiceStats', request.values)
    # return response
    return "200"

# accepts sms messages from GET or POST
@app.route('/inbound_sms', methods=['GET', 'POST'])
def inbound_sms():
    
    # return response
    return "200"

The next route /post_conference handles what to do with the caller after their call with the agent has ended but before the termination of the call. We will check the settings menu in the config.json file to see if enableExitMessage is set to True. If it is, we will play a short exit message for the caller using the verbiage in the menu. If enableTextSummary is set to True, we will use the Create Message API to send a message to the caller thanking them for their call. Lastly, we will check if enableExitSurvey is enabled. If so, we will redirect to the /get_survey route which will be shown below.

@app.route('/post_conference', methods=['GET', 'POST'])
def post_conference():
    response = VoiceResponse()
    print("kevin here")
    pprint.pprint(request.values)
    # Check if announcment should be played
    if ccConfig['settings']['enableExitMessage']:
        response.say(ccConfig['settings']['messages']['exitMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    if ccConfig['settings']['enableExitMessageFile']:
        response.play(ccConfig['settings']['messages']['exitMessageFile'])

    # Send Text Summary Message
    #if ccConfig['settings']['enableTextSummary']:
    #    client = signalwire_client(SIGNALWIRE_PROJECT, SIGNALWIRE_TOKEN, signalwire_space_url = SIGNALWIRE_SPACE)
    #    message = client.messages.create(
    #        body='Thank you for participating in our demo today!',
    #        from_=ccConfig['settings']['outboundPhoneNumber'],
    #        to=request.values.From)
    #    print("message sent: " + message.sid)

    # Check if survey should be enabled
    if ccConfig['settings']['enableExitSurvey']:
        response.say(ccConfig['settings']['messages']['surveyEntryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])
        response.redirect(HOSTNAME + "/get_survey", Method="POST") 

    return str(response)

The next route /enter_queue is the web entry for entering the queue. The queue name is passed through as a query string variable. If a queue with the specified name doesn't currently exist within our ActiveQueues array, we will use Enqueue to create one.

# Web entry for entering a queue, passing queue name as a querystring var
@app.route('/enter_queue', methods=['GET', 'POST'])
def enter_queue():

    #queueInfo = client.queues.create(friendly_name=queue)
    if str(request.values.get("name")) not in ActiveQueues:
        ActiveQueues.append(str(request.values.get("name")))

    response = enqueue( str(request.values.get("name")) )
    # return response
    return str(response)

The route /connect_agent is pretty straightforward - this will accept GET and POST requests in order to connect the agent to the conference with the caller.

@app.route('/connect_agent', methods=['GET', 'POST'])
def connect_agent():

    # Logic To Find Best Agent
    response = dial_conference( str(request.values.get("name")) )
    # return response
    return str(response)

The next route /connect_caller is the exact opposite - it will accept GET and POST requests to connect the caller to the conference with the agent!

# connects the caller to the conference with agent
@app.route('/connect_caller', methods=['GET', 'POST'])
def connect_caller():
    response = dial_conference( str(request.values.get("name")) )
    # return response
    return str(response)

The next route /inbound_voice will handle inbound calls to our phone number. We will first check the settings menu to see if enableRecording is True and then play an entry message for the caller using the verbiage in the messages submenu of the settings menu. We will check if any announcements should be played and then redirect the main menu of options for the caller to sort through in the mainMenu submenu of the settings menu in the config.json file.

@app.route('/inbound_voice', methods=['GET', 'POST'])
def inbound_voice():
    response = VoiceResponse()

    # Check if call should be recorded
    if ccConfig['settings']['enableRecording']:
        enableRecording(str(request.values.get('CallSid')))

    response.say(ccConfig['settings']['messages']['entryMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    # Check if announcment should be played
    if ccConfig['settings']['enableAnnouncment']:
        response.say(ccConfig['settings']['messages']['announcmentMessage'], voice=ccConfig['settings']['textToSpeech']['voice'])

    # Redirect to the main menu set in config.json
    response.redirect(HOSTNAME + "/get_menu?menu=" + ccConfig['settings']['mainMenu'], method="POST")
    
    # return response
    return str(response)

The next route get_menu dynamically builds the IVR menu tree from the JSON file and uses action routing to continually progress through the various submenus. We will loop through the menus starting at the main menu and ask the user to select input based on the option they'd like to select. After the input has been detected from the caller, we will reroute back to the /get_menu route except we will specify the next menu to rotate through. This process will continue until the user has reached an agent or ended the call.

@app.route('/get_menu', methods=['GET', 'POST'])
def get_menu():
    response = VoiceResponse()

    # read menus from config
    menus = ccConfig['settings']['menus']

    # check to see if a default menu was specified, else default to "main"
    menu = request.values.get("menu")
    if menu not in menus:
        menu = "main"

    # read input_type variable
    input_type = request.values.get("input_type")

    # check if user input was provided via dtmf entry
    if input_type == "dtmf":
        # get digits pressed at menu
        digits = request.values.get("Digits")
        input_action = menus[menu][digits]["action"]
        response.redirect(url=input_action)
        response.hangup()
    else:
        # no user input was detected, so lets present a menu
        gather = Gather(action='/get_menu' + "?menu=" + menu, input='dtmf', timeout="5", method='POST', numDigits="1")

        # loop through menus and generate menu options
        for key in menus[menu]:
            print(key, '->', menus[menu][key]["verbiage"])
            gather.say(menus[menu][key]["verbiage"], voice=ccConfig['settings']['textToSpeech']['voice'])

        # add menu to response
        response.append(gather)
        response.hangup()

    # return response
    return str(response)

The last two routes are quite simple! /get_survey and /get_voicemail are both basic examples of routes that could take a survey after a call has ended or gather a voicemail when an agent is not available.

# Mock survey rendering for demo 
@app.route('/get_survey', methods=['GET', 'POST'])
def get_survey():
    response = VoiceResponse()
    response.say('This is a survey placeholder...', voice=ccConfig['settings']['textToSpeech']['voice'])
    return str(response)

# Mock voicemail rendering for demo
@app.route('/get_voicemail', methods=['GET', 'POST'])
def get_voicemail():
    response = VoiceResponse()
    response.say('You have reached our voicemail, please leave a message.', voice=ccConfig['settings']['textToSpeech']['voice'])
    response.hangup()
    return str(response)

Build and Run on Docker

  1. Build on docker
./docker-build
  1. Run your image
docker run --publish 80:80 sw-call-center-demo

or if you have docker compose installed, type

docker-compose up

or

docker-compose up -d
  1. The application will run on port 80

Build and Run Natively

  1. Edit config.json to build out your contact center.
  2. From command line run, python3 app.py

You may need to use an SSH tunnel for testing this code if running on your local machine. – we recommend ngrok. You can learn more about how to use ngrok here.

Sign Up Here

If you would like to test this example out, you can create a SignalWire account and space here.

Please feel free to reach out to us on our Community Slack or create a Support ticket if you need guidance!


What’s Next

Check out the full code on our SignalWire Github Repo!

Did this page help you?