Warm Transfer with Python and Flask

January 10, 2017
Written by
Samuel Mendes
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Paul Kamp
Twilion
Kat King
Twilion

warm-transfer-python

Have you ever been disconnected from a support call while being transferred to someone else?  That couldn't have left a great impression on you...

Warm transfer eliminates this problem - it allows your agents to have the ability to dial in another agent in real time.

Today we'll add warm transfer to a Python and Flask application so we can engender warm feelings among customers talking to support.

Here's how it works at a high level:

  1. The first agent becomes available by connecting through the web client.
  2. The second agent becomes available by connecting through the web client.
  3. A customer calls our support line.
  4. The client stays on hold while the first agent joins the call.
  5. While the first agent is on the phone with the client, he or she dials the second agent into the call.
  6. Once the second agent is on the call, the first one can disconnect. The client and the second agent stay on the call.

Let's get started!  Clone the sample application from Github.

Set Up The Voice Web Hook

First, let's configure the voice web-hook for the Twilio number that customers will dial when they want to talk to a support agent. 

Twilio Console for Warm Transfer

In production, this should be the public-facing URL for your app.

One option to expose a development URL from your local machine is to use ngrok.  Your URL would then be something like:

 https://<your-ngrok-id>.ngrok.io/conference/connect/client

Editor: this is a migrated tutorial. Find the original code at https://github.com/TwilioDevEd/warm-transfer-flask/

from flask import render_template, jsonify, request, url_for
from . import token
from . import call
from . import twiml_generator
from .models import ActiveCall

AGENT_WAIT_URL = 'http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical'


def routes(app):
    app.route('/')(root)
    app.route('/conference/connect/client', methods=['POST'])(connect_client)
    app.route('/<agent_id>/token', methods=['POST'])(generate_token)
    app.route('/conference/<agent_id>/call', methods=['POST'])(call_agent)
    app.route('/conference/wait', methods=['POST'])(wait)
    app.route('/conference/<conference_id>/connect/<agent_id>', methods=['POST'])(
        connect_agent
    )


def root():
    return render_template('index.html')


def connect_client():
    conference_id = request.form['CallSid']
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent1', conference_id=conference_id, _external=True
    )
    call.call_agent('agent1', connect_agent_url)
    ActiveCall.create('agent1', conference_id)
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, url_for('wait'), False, True
        )
    )


def generate_token(agent_id):
    return jsonify(token=token.generate(agent_id), agentId=agent_id)


def call_agent(agent_id):
    conference_id = ActiveCall.conference_id_for(agent_id)
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent2', conference_id=conference_id, _external=True
    )
    return call.call_agent('agent2', connect_agent_url)


def wait():
    return str(twiml_generator.generate_wait())


def connect_agent(conference_id, agent_id):
    exit_on_end = 'agent2' == agent_id
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, AGENT_WAIT_URL, True, exit_on_end
        )
    )

Awesome, now you've got a webhook in place.  Next up, we'll look at some of the code.

Connect an Agent to a Call

Here you can see all front-end code necessary to connect an agent using Twilio's Voice Web Client.

We need three things to have a live web client:

  • A capability token (provided by our Flask app)
  • A unique identifier (string) for each agent
  • Event listeners to handle different Twilio-triggered events
(function () {
    let currentAgentId;
    const callStatus = document.getElementById('call-status');
    const connectAgent1Button = document.getElementById("connect-agent1-button");
    const connectAgent2Button = document.getElementById("connect-agent2-button");

    const answerCallButton = document.getElementById("answer-call-button");
    const hangupCallButton = document.getElementById("hangup-call-button");
    const dialAgent2Button = document.getElementById("dial-agent2-button");

    const connectAgentRow = document.getElementById('connect-agent-row');
    const connectedAgentRow = document.getElementById('connected-agent-row');

    connectAgent1Button.onclick = () => { connectAgent('agent1') };
    connectAgent2Button.onclick = () => { connectAgent('agent2') };
    dialAgent2Button.onclick = dialAgent2;

    const device = new Twilio.Device();

    device.on('ready', function (device) {
        callStatus.innerText = "Ready";
        connectAgent1Button.classList.add('hidden');
        connectAgent2Button.classList.add('hidden');
        agentConnectedHandler(currentAgentId);
    });

    device.on('offline', function (device) {
        callStatus.innerText = "Offline";
        connectAgent1Button.disabled = false;
        connectAgent2Button.disabled = false;
        connectedAgentRow.classList.add('hidden');
        connectAgentRow.classList.remove('hidden');
    });

    // Callback for when Twilio Client receives a new incoming call
    device.on('incoming', function (connection) {
        callStatus.innerText = "Incoming support call";

        // Set a callback to be executed when the connection is accepted
        connection.accept(function () {
            callStatus.innerText = "In call with customer";
            answerCallButton.disabled = true;
            hangupCallButton.disabled = false;
            dialAgent2Button.disabled = false;
        });

        // Set a callback on the answer button and enable it
        answerCallButton.onclick = () => {
            connection.accept();
        };
        answerCallButton.disabled = false;
    });

    /* Report any errors to the call status display */
    device.on('error', function (error) {
        callStatus.innerText = `ERROR: ${error.message}`;
        connectAgent1Button.disabled = false;
        connectAgent2Button.disabled = false;
    });

    // Callback for when the call finalizes
    device.on('disconnect', function (connection) {
        dialAgent2Button.disabled = true;
        hangupCallButton.disabled = true;
        answerCallButton.disabled = true;
        callStatus.innerText = `Connected as: ${currentAgentId}`;
    });

    hangupCallButton.onclick = () => { device.disconnectAll() };

    function connectAgent(agentId) {
        connectAgent1Button.disabled = true;
        connectAgent2Button.disabled = true;
        currentAgentId = agentId;
        fetch('/' + agentId + '/token', { method: 'POST' })
            .then(response => response.json())
            .then(function (data) {
                device.setup(data.token);
            })
            .catch(error => {
                callStatus.innerText = `ERROR: ${error.message}`;
            });
    }

    function dialAgent2() {
        fetch('/conference/' + currentAgentId + '/call', { method: 'POST' });
    }

    function agentConnectedHandler(agentId) {
        connectAgentRow.classList.add('hidden');
        connectedAgentRow.classList.remove('hidden');
        callStatus.innerText = `Connected as: ${agentId}`;

        if (agentId === 'agent1') {
            dialAgent2Button.classList.remove('hidden');
            dialAgent2Button.attributes.disabled = true;
        } else {
            dialAgent2Button.classList.add('hidden');
        }
    }

})();

In the next step we'll take a closer look at capability token generation.

Generate a Capability Token

In order to connect the Twilio Voice Web Client we need a capability token.

To allow incoming connections through the web client an identifier must be provided when generating the token.

from twilio.jwt.access_token import AccessToken
from twilio.jwt.access_token.grants import VoiceGrant

from warm_transfer_flask import app


def generate(agent_id):
    # Create access token with credentials
    access_token = AccessToken(
        app.config['TWILIO_ACCOUNT_SID'],
        app.config['TWILIO_API_KEY'],
        app.config['TWILIO_API_SECRET'],
        identity=agent_id,
    )

    # Create a Voice grant and add to token
    voice_grant = VoiceGrant(
        incoming_allow=True,  # add to allow incoming calls
    )
    access_token.add_grant(voice_grant)

    return access_token.to_jwt().decode()

Next up let's see how to handle incoming calls.

Handle Incoming Calls

For this tutorial we used fixed identifier strings like agent1 and agent2 but you can use any unique application generated string for your call center clients. These identifiers will be used to create outbound calls to the specified agent using the Twilio REST API.

When a client makes a call to our Twilio number the application receives a POST request asking for instructions. We'll use TwiML to instruct the client to join a conference room and use the Twilio REST API client to invite (and initiate a call to) the first agent.

When providing instructions to the client, we also provide a waitUrl. This URL is another end point of our application and will return more TwiML to SAY welcome to the user and also PLAY some music while on hold. Take a look at the code here.

We use the client's CallSid as the conference identifier. Since all participants need this identifier to join the conference, we'll need to store it in a database so that we can grab it when we dial the second agent into the conference.

from flask import render_template, jsonify, request, url_for
from . import token
from . import call
from . import twiml_generator
from .models import ActiveCall

AGENT_WAIT_URL = 'http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical'


def routes(app):
    app.route('/')(root)
    app.route('/conference/connect/client', methods=['POST'])(connect_client)
    app.route('/<agent_id>/token', methods=['POST'])(generate_token)
    app.route('/conference/<agent_id>/call', methods=['POST'])(call_agent)
    app.route('/conference/wait', methods=['POST'])(wait)
    app.route('/conference/<conference_id>/connect/<agent_id>', methods=['POST'])(
        connect_agent
    )


def root():
    return render_template('index.html')


def connect_client():
    conference_id = request.form['CallSid']
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent1', conference_id=conference_id, _external=True
    )
    call.call_agent('agent1', connect_agent_url)
    ActiveCall.create('agent1', conference_id)
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, url_for('wait'), False, True
        )
    )


def generate_token(agent_id):
    return jsonify(token=token.generate(agent_id), agentId=agent_id)


def call_agent(agent_id):
    conference_id = ActiveCall.conference_id_for(agent_id)
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent2', conference_id=conference_id, _external=True
    )
    return call.call_agent('agent2', connect_agent_url)


def wait():
    return str(twiml_generator.generate_wait())


def connect_agent(conference_id, agent_id):
    exit_on_end = 'agent2' == agent_id
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, AGENT_WAIT_URL, True, exit_on_end
        )
    )

Now let's see how to provide TwiML instructions to the client.

Provide TwiML Instructions For The Client

Here we create a TwiMLResponse that will contain a DIAL verb with a CONFERENCE noun that will instruct the JavaScript client to join a specific conference room.

from twilio.twiml.voice_response import VoiceResponse, Dial


def generate_wait():
    twiml_response = VoiceResponse()
    wait_message = (
        'Thank you for calling. Please wait in line for a few seconds.'
        ' An agent will be with you shortly.'
    )
    wait_music = 'http://com.twilio.music.classical.s3.amazonaws.com/BusyStrings.mp3'
    twiml_response.say(wait_message)
    twiml_response.play(wait_music)
    return str(twiml_response)


def generate_connect_conference(call_sid, wait_url, start_on_enter, end_on_exit):
    twiml_response = VoiceResponse()
    dial = Dial()
    dial.conference(
        call_sid,
        start_conference_on_enter=start_on_enter,
        end_conference_on_exit=end_on_exit,
        wait_url=wait_url,
    )
    return str(twiml_response.append(dial))

Next up, we will look at how to dial our first agent into the call.

Dial The First Agent Into the Call

For our app we created a call module to handle dialing our agents. This module uses Twilio's REST API to create a new call. The create method receives the following parameters:

  1. from_: Your Twilio number
  2. to: The agent web client's identifier (agent1 or agent2)
  3. url: A URL to ask for TwiML instructions when the call connects

Once the agent answers the call in the web client, a request is made to the callback URL instructing this call to join the conference where the client is already waiting.

from twilio.rest import Client

from warm_transfer_flask import app


def call_agent(agent_id, callback_url):
    account_sid = app.config.get('TWILIO_ACCOUNT_SID')
    api_key = app.config.get('TWILIO_API_KEY')
    api_secret = app.config.get('TWILIO_API_SECRET')
    twilio_number = app.config.get('TWILIO_NUMBER')
    client = Client(api_key, api_secret, account_sid)

    to = 'client:' + agent_id
    from_ = twilio_number
    call = client.calls.create(to, from_, url=callback_url)

    return call.sid

With that in mind, let's see how to add the second agent to the call.

Dial The Second Agent Into the Call

When the client and the first agent are both in the call we are ready to perform the warm transfer to a second agent.

The first agent makes a request passing its identifier to allow us to look for the conference_id needed to invite the second agent. Since we already have a call module, we can simply use the call_agent function to connect the second agent.

from flask import render_template, jsonify, request, url_for
from . import token
from . import call
from . import twiml_generator
from .models import ActiveCall

AGENT_WAIT_URL = 'http://twimlets.com/holdmusic?Bucket=com.twilio.music.classical'


def routes(app):
    app.route('/')(root)
    app.route('/conference/connect/client', methods=['POST'])(connect_client)
    app.route('/<agent_id>/token', methods=['POST'])(generate_token)
    app.route('/conference/<agent_id>/call', methods=['POST'])(call_agent)
    app.route('/conference/wait', methods=['POST'])(wait)
    app.route('/conference/<conference_id>/connect/<agent_id>', methods=['POST'])(
        connect_agent
    )


def root():
    return render_template('index.html')


def connect_client():
    conference_id = request.form['CallSid']
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent1', conference_id=conference_id, _external=True
    )
    call.call_agent('agent1', connect_agent_url)
    ActiveCall.create('agent1', conference_id)
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, url_for('wait'), False, True
        )
    )


def generate_token(agent_id):
    return jsonify(token=token.generate(agent_id), agentId=agent_id)


def call_agent(agent_id):
    conference_id = ActiveCall.conference_id_for(agent_id)
    connect_agent_url = url_for(
        'connect_agent', agent_id='agent2', conference_id=conference_id, _external=True
    )
    return call.call_agent('agent2', connect_agent_url)


def wait():
    return str(twiml_generator.generate_wait())


def connect_agent(conference_id, agent_id):
    exit_on_end = 'agent2' == agent_id
    return str(
        twiml_generator.generate_connect_conference(
            conference_id, AGENT_WAIT_URL, True, exit_on_end
        )
    )

Next up, we'll look at how to handle the first agent leaving the call.

The First Agent Leaves the Call

When the three participants have joined the same call, the first agent has served his or her purpose. Now agent #1 can drop the call, leaving agent #2 and the client to discuss support matters.

It is important to notice the differences between the TwiML each one of the participants received when joining the call:

  • Both agent one and two have startConferenceOnEnter set to true.
  • For the client calling and for agent two, endConferenceOnExit is set to true.

Translated, this means a conference will start when either agent joins the call.  It also means the client or agent #2 disconnecting will hang up the call.

from twilio.twiml.voice_response import VoiceResponse, Dial


def generate_wait():
    twiml_response = VoiceResponse()
    wait_message = (
        'Thank you for calling. Please wait in line for a few seconds.'
        ' An agent will be with you shortly.'
    )
    wait_music = 'http://com.twilio.music.classical.s3.amazonaws.com/BusyStrings.mp3'
    twiml_response.say(wait_message)
    twiml_response.play(wait_music)
    return str(twiml_response)


def generate_connect_conference(call_sid, wait_url, start_on_enter, end_on_exit):
    twiml_response = VoiceResponse()
    dial = Dial()
    dial.conference(
        call_sid,
        start_conference_on_enter=start_on_enter,
        end_conference_on_exit=end_on_exit,
        wait_url=wait_url,
    )
    return str(twiml_response.append(dial))

And that's it!

We have just implemented warm transfers using Python and Flask. Now your clients won't get disconnected from support calls while they are being transferred.

Now let's look at some other great features Twilio makes it easy to add.

Where to Next?

If you're a Python developer working with Twilio you'll almost certainly love these tutorials:

Automated Survey

Instantly collect structured data from your users with a survey conducted over a call or SMS text messages.

Browser Calls

Learn how to use Twilio Client to make browser-to-phone and browser-to-browser calls with ease.

Did this help?

Thanks for checking this tutorial out! Let us know on Twitter what you've built... or what you're building.