Choose Your Own Adventure Presentations: Wizard Mode Part 3 of 3 with Flask, Reveal.js and WebSockets

July 06, 2015
Written by
Matt Makai
Twilion

choose-your-own-adventure

You’ve coded your way through the original Choose Your Own Adventure Presentations story, the Wizards Only gatekeeper and the Web Forms trials blog posts. Now it’s time to pull out our magical wands for one more trick and complete our application-building quest.

Voting with a wand (or smartphone)

In this final tutorial we’ll wrap up our Flask application with a new ability that will allow the audience to vote with a web browser on their wand…or smartphone. We will write code to keep track of audience votes from web browsers using websocket connections. The new browser-based voting can augment our existing SMS-based voting for presentations when cell signals aren’t working well.

Roll up the sleeves on your cloak and get ready to write a bit more Python code as we finish out our Choose Your Own Adventure Presentations application.

What We’ll Need

If you followed along with part 1 and part 2 of this series then you’ve already set up all of the dependencies and the database connections we’ll need for this part of the tutorial.
If you haven’t read parts 1 or 2 yet, that’s okay. Here’s a quick recap of the tools and libraries we used to build the application so far.

  • PostgreSQL for persistent storage. PostgreSQL drives the Wizards Only mode by keeping track of our authentication and presentation data.
  • Redis and redis-py for ephemeral storage. Redis keeps the vote counts so make sure redis-server process is running the background while developing the application.
  • The psycopg2 Python driver to connect to PostgreSQL. Psycopg2 will continue to drive the connect between our web application and the database.
  • Flask-SocketIO for creating and working with websockets in our Flask application.
  • Flask-Login for authentication. In part 1 we set up Flask-Login to protect the Wizards Only pages.
  • Flask-WTF for web form handling. We’re creating a bunch of new pages in this post so we’ll use Flask-WTF extensively in the following sections.

You can get caught up with us by working through the code in parts 1 and 2 or just clone the CYOA Presentations repository tutorial-step-5 tag stage. Here are the commands to execute if you’re going to clone the repository to get up to speed:

 

git clone git@github.com:makaimc/choose-your-own-adventure-presentations
cd choose-your-own-adventure-presentations
git checkout -b tutorial tags/tutorial-step-5

 

If you need a walkthrough to create a virtualenv, re-install the dependencies in requirements.txt, export the environment variables for the project and sync the database tables, there are detailed explanations for each of those steps in part 1 of this tutorial. Note that if you make a typo somewhere along the way in this post, you can compare your version with the tutorial-step-6 tag.

Manage Decisions View

We need to create new Wizards Only screens to set up the web browser voting when we want to enable it for presentations. Note that we don’t have to enable voting via a web browser, we can continue to just use SMS for votes. However, these new screens simply give us an option to replace or augment SMS voting which is great if the cell phone service does not work well in a venue. After we write the code in this section and fire up our development server, our new admin screens should look like these screenshots below.

new web browser voting option

When we create a presentation there will be an option to manage web browser-based voting as shown above. The Manage choices link takes the wizard user to a new screen where decisions that have been created can be edited, deleted or created.

list of decisions for this presentation

Decisions can be created and saved via the simple form as seen below.

screenshot of creating a new decision for a presentation

Let’s get into the new code we need to write to transform the above screenshots from images into a part of our working application. Crank open the existing cyoa/models.py file in your favorite editor. We’re going to add the first highlighted line to the Presentation model that represents a foreign key to the new Decision model. The Decision model will hold the slugs to the webpages that will allow someone in the audience to vote on a decision. Add the new line to Presentation and create the new Decision model as shown below.

 


class Presentation(db.Model):
    """
        Contains data regarding a single presentation.
    """
    __tablename__ = 'presentations'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(64), unique=True)
    slug = db.Column(db.String(128), unique=True)
    filename = db.Column(db.String(256))
    is_visible = db.Column(db.Boolean, default=False)
    decisions = db.relationship('Decision', lazy='dynamic')

    def __repr__(self):
        return '<Presentation %r>' % self.name


class Decision(db.Model):
    """
        A branch in the storyline that an audience member can vote on.
        Must map directly into what is stored in Redis.
    """
    __tablename__ = 'choices'
    id = db.Column(db.Integer, primary_key=True)
    slug = db.Column(db.String(128))
    first_path_slug = db.Column(db.String(128))
    second_path_slug = db.Column(db.String(128))
    presentation = db.Column(db.Integer, db.ForeignKey('presentations.id'))

    def __repr__(self):
        return '<Decision %r>' % self.slug

 

The new models code above allows us to save and manipulate web browser-based decisions that can be associated with specific presentations.

Next we need to update the cyoa/forms.py file with a new DecisionForm class. Within cyoa/forms.py append the following new form class as highlighted below.

 


from flask.ext.wtf import Form
from wtforms import StringField, PasswordField, BooleanField, SubmitField, 
                    DateField, IntegerField
from wtforms.validators import Required, Length, Regexp, EqualTo
from wtforms import ValidationError
from .models import Wizard


class LoginForm(Form):
    wizard_name = StringField('Wizard Name',
                              validators=[Required(), Length(1, 32)])
    password = PasswordField('Password', validators=[Required(),
                                                     Length(1, 32)])

    def validate(self):
        if not Form.validate(self):
            return False
        user = Wizard.query.filter_by(wizard_name=self.
                                      wizard_name.data).first()
        if user is not None and not user.verify_password(self.password.data):
            self.password.errors.append('Incorrect password.')
            return False
        return True


class PresentationForm(Form):
    name = StringField('Presentation name', validators=[Required(),
                                                        Length(1, 60)])
    filename = StringField('File name', validators=[Required(),
                                                    Length(1, 255)])
    slug = StringField('URL slug', validators=[Required(),
                                               Length(1, 255)])
    is_visible = BooleanField()


class DecisionForm(Form):
    slug = StringField('URL slug', validators=[Required(),
                                               Length(1, 128)])
    first_path_slug = StringField('A word for the first path. Must be '
                                  'lowercase. No spaces.',
                                  validators=[Required(), Length(1, 64),
                                              Regexp('[a-z0-9] ', message=
                                              'Choice must be lowercase '
                                              'with no whitespace.')])
    second_path_slug = StringField('A word for the second path. Must be '
                                   'lowercase. No spaces.',
                                   validators=[Required(), Length(1, 64),
                                               Regexp('[a-z-0-9] ', message=
                                               'Choice must be lowercase '
                                               'with no whitespace.')])

 

The DecisionForm is used to create and edit decisions through the user interface. We’ve included some basic validation to ensure  proper URL slugs are input by the user.

The next file that’s critical for getting our web browser-based voting up and running is cyoa/wizard_views.py. The changes we’re going to make in this file will allow us to modify the decisions found in presentations so users can only vote in the web browser on choices we’ve created for them. In part two of our tutorial, we included stub functions in this wizard_views.py file knowing that we’d flesh them out in this post. Make sure to remove the pass keyword from the body of those functions and insert the highlighted code shown below.

 


from flask import render_template, redirect, url_for
from flask.ext.login import login_required

from . import app, db
from .models import Presentation, Decision
from .forms import PresentationForm, DecisionForm


@app.route('/wizard/presentations/')
@login_required
def wizard_list_presentations():
    presentations = Presentation.query.all()
    return render_template('wizard/presentations.html',
                           presentations=presentations)


@app.route('/wizard/presentation/', methods=['GET', 'POST'])
@login_required
def wizard_new_presentation():
    form = PresentationForm()
    if form.validate_on_submit():
        presentation = Presentation()
        form.populate_obj(presentation)
        db.session.add(presentation)
        db.session.commit()
        return redirect(url_for('wizard_list_presentations'))
    return render_template('wizard/presentation.html', form=form, is_new=True)


@app.route('/wizard/presentation/<int:id>/', methods=['GET', 'POST'])
@login_required
def wizard_edit_presentation(id):
    presentation = Presentation.query.get_or_404(id)
    form = PresentationForm(obj=presentation)
    if form.validate_on_submit():
        form.populate_obj(presentation)
        db.session.merge(presentation)
        db.session.commit()
        db.session.refresh(presentation)
    return render_template('wizard/presentation.html', form=form,
                           presentation=presentation)


@app.route('/wizard/presentation/<int:pres_id>/decisions/')
@login_required
def wizard_list_presentation_decisions(pres_id):
    presentation = Presentation.query.get_or_404(pres_id)
    return render_template('wizard/decisions.html', presentation=presentation,
                           decisions=presentation.decisions.all())


@app.route('/wizard/presentation/<int:pres_id>/decision/',
           methods=['GET', 'POST'])
@login_required
def wizard_new_decision(pres_id):
    form = DecisionForm()
    if form.validate_on_submit():
        decision = Decision()
        form.populate_obj(decision)
        decision.presentation = pres_id
        db.session.add(decision)
        db.session.commit()
        return redirect(url_for('wizard_list_presentation_decisions',
                                pres_id=pres_id))
    return render_template('wizard/decision.html', form=form, is_new=True,
                           presentation_id=pres_id)


@app.route('/wizard/presentation/<int:presentation_id>/decision/'
           '<int:decision_id>/', methods=['GET', 'POST'])
@login_required
def wizard_edit_decision(presentation_id, decision_id):
    decision = Decision.query.get_or_404(decision_id)
    form = DecisionForm(obj=decision)
    if form.validate_on_submit():
        form.populate_obj(decision)
        decision.presentation = presentation_id
        db.session.merge(decision)
        db.session.commit()
        db.session.refresh(decision)
        return redirect(url_for('wizard_list_presentation_decisions',
                        pres_id=presentation_id))
    return render_template('wizard/decision.html', form=form,
                           decision=decision, presentation_id=presentation_id)


@app.route('/wizard/presentation/<int:pres_id>/decision/'
           '<int:decision_id>/delete/')
@login_required
def wizard_delete_decision(pres_id, decision_id):
    presentation = Presentation.query.get_or_404(pres_id)
    decision = Decision.query.get_or_404(decision_id)
    db.session.delete(decision)
    db.session.commit()
    return redirect(url_for('wizard_list_presentation_decisions',
                    pres_id=presentation.id))

 

We added code to list, create, edit and delete decisions in the system. These are standard operations for an administrative interface that any self-respecting wizard would expect.

Alright, that’s all the Python code we need at the moment for the new Wizards Only screens. Now we need to create some new templates to generate the HTML for our decision screens. Create a new file named cyoa/templates/wizard/decisions.html with the following markup.

 

{% extends "base.html" %}

{% block nav %}
    {% include "nav.html" %}
{% endblock %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-10">
            <h1>{{ presentation.name }} Decisions</h1>
            {% if decisions|length == 0 %}
                No web browser voting enabled for 
                <em>{{ presentation.name }}</em>.
                <a href="{{ url_for('wizard_new_decision', pres_id=presentation.id) }}">Add a decision point for this presentation</a>.
            {% else %}
                <table class="table">
                    <thead>
                        <tr>
                            <th>Decision</th>
                            <th>First story path</th>
                            <th>Second story path</th>
                            <th>Delete</th>
                            <th>View choice</th>
                        </tr>
                    </thead>
                    <tbody>
                    {% for d in decisions %}
                        <tr>
                            <td><a href="{{ url_for('wizard_edit_decision', presentation_id=presentation.id, decision_id=d.id) }}">{{ d.slug }}</a></td>
                            <td>{{ d.first_path_slug }}</td>
                            <td>{{ d.second_path_slug }}</td>
                            <td><a href="{{ url_for('wizard_delete_decision', pres_id=presentation.id, decision_id=d.id) }}" class="btn btn-danger">X</a></td>
                            <td>{{ d.slug }}</td>
                        </tr>
                    {% endfor %}
                    </tbody>
                </table>
            {% endif %}
            <div class="btn-top-margin">
            <a href="{{ url_for('wizard_new_decision', pres_id=presentation.id) }}" class="btn btn-primary">Add decision point</a>
            </div>
        </div>
    </div>
</div>
{% endblock %}

 

The above template markup loops through existing decisions and displays each one’s name and first and second story paths along with options to delete or view the decision’s webpage. If no decisions are returned from the database for the selected presentation, then a simple explanation will show “No web browser voting enabled for [presentation name].” There must be decision points created in the user interface for the browser-based voting to work.

There also needs to be a template file for creating new decisions and editing existing ones. Create the file cyoa/templates/wizard/decision.html and insert the following markup. Make sure you’ve named this file without an ‘s’ at the end of “decision” so it doesn’t overwrite the previous template we just created.

 

{% extends "base.html" %}

{% block nav %}
    {% include "nav.html" %}
{% endblock %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-6">
            {% from "partials/_formhelpers.html" import render_field %}
            {% if is_new %}
            <form action="{{ url_for('wizard_new_decision', pres_id=presentation_id) }}" 
                  method="post">
            {% else %}
            <form action="{{ url_for('wizard_edit_decision', presentation_id=presentation_id, decision_id=decision.id) }}" method="post">
            {% endif %}
                <div>
                    {{ form.csrf_token }}
                    {{ render_field(form.slug) }}
                    {{ render_field(form.first_path_slug) }}
                    {{ render_field(form.second_path_slug) }}
                </div>
                <div>
                <input type="submit" class="btn btn-success btn-top-margin" 
                       value="Save Decision"></input>
                </div>
            </form>
        </div>
    </div>
</div>
{% endblock %}

 

With the above template we have a way to create and edit decisions. The template is generated with a form submit to either create a new decision if the “New Decision” button was clicked or edit the existing decision when modifying an existing decision.

Finally, it’s time to test out our new code! Create the new Decision database table by running the following command at the base directory of our project.

 

(cyoa)$ python manage.py syncdb

 

Now run the development server with the following command as we’ve performed in previous blog posts.

 

(cyoa)$ python manage.py runserver

 

Head to http://localhost:5001/wizard to check out the current version of our application.

So far so good. Sign in using the defaults “gandalf” as a username and “thegrey” as the password that were created in part 1 of the tutorial.

If there are existing presentations in your application, click on the “Manage choices” link for one of the presentations, or create a new presentation then click the link. Unfortunately, upon clicking “Manage choices” we will suddenly get the following error page…

foreign-key-error.png

Uh oh. What’s happening here? It looks like the new foreign key relationship in the Presentation table is causing a database error. Although we created the new Decision table with the syncdb command, it did not create the foreign key relationship column in the Presentation table.

How do we fix this problem? We need to ensure the Presentation table has the appropriate column in the database. To accomplish that we have to

  1. Use a library to perform a schema migration based on our updated SQLAlchemy models
  2. Add the column manually to the database with an ALTER TABLE statement
  3. Drop and recreate the database and re-sync the database tables.

For simplicity’s sake, in this post we’ll use the third method of dropping and recreating the database. Note that unfortunately the existing wizards and presentations in the database will be deleted when we blow away the database so we will also walk through recreating them.

Kill the development server process with Ctrl-C and execute the following commands to recreate the PostgreSQL database.

 

(cyoa)$ dropdb cyoa

(cyoa)$ createdb cyoa

(cyoa)$ python manage.py syncdb

 

We also need to create a new Wizard user in the database since the previous one was deleted.

 

(cyoa)$ python manage.py shell
>>> db.session.add(Wizard('gandalf', 'thegrey'))
>>> db.session.commit()
>>> exit()

 

Finishing up browser voting

With our new Decision model in place, let’s finish out this tutorial by adding the browser-based voting functionality. Open up cyoa/views.py and update the following highlighted lines.

 


import cgi
from flask import render_template, abort, request
from flask import redirect, url_for
from flask.ext.login import login_user, logout_user, login_required, \
                            current_user
from jinja2 import TemplateNotFound
from twilio import twiml
from twilio.rest import TwilioRestClient

from .config import TWILIO_NUMBER
from .forms import LoginForm
from .models import Wizard, Decision

from . import app, redis_db, socketio, login_manager
from .models import Presentation

client = TwilioRestClient()

@login_manager.user_loader
def load_user(userid):
    return Wizard.query.get(int(userid))


@app.route('/', methods=['GET'])
def list_public_presentations():
    presentations = Presentation.query.filter_by(is_visible=True)
    return render_template('list_presentations.html',
                           presentations=presentations)


@app.route('/<slug>/', methods=['GET'])
def presentation(slug):
    presentation = Presentation.query.filter_by(is_visible=True,
                                                slug=slug).first()
    if presentation:
        return render_template('/presentations/' + presentation.filename)
    abort(404)


@app.route('/cyoa/twilio/webhook/', methods=['POST'])
def twilio_callback():
    to = request.form.get('To', '')
    from_ = request.form.get('From', '')
    message = request.form.get('Body', '').lower()
    if to == TWILIO_NUMBER:
        redis_db.incr(cgi.escape(message))
        socketio.emit('msg', {'div': cgi.escape(message),
                              'val': redis_db.get(message)},
                      namespace='/cyoa')
    resp = twiml.Response()
    resp.message("Thanks for your vote!")
    return str(resp)


@app.route('/wizard/', methods=['GET', 'POST'])
def sign_in():
    form = LoginForm()
    if form.validate_on_submit():
        wizard = Wizard.query.filter_by(wizard_name=
                                        form.wizard_name.data).first()
        if wizard is not None and wizard.verify_password(form.password.data):
            login_user(wizard)
            return redirect(url_for('wizard_list_presentations'))
    return render_template('wizard/sign_in.html', form=form, no_nav=True)


@app.route('/sign-out/')
@login_required
def sign_out():
    logout_user()
    return redirect(url_for('sign_in'))


@app.route('/<presentation_slug>/vote/<decision_slug>/', methods=['GET'])
def decision(presentation_slug, decision_slug):
    presentations = Presentation.query.filter_by(slug=presentation_slug)
    if presentations.count() > 0:
        presentation = presentations.first()
        decision = Decision.query.filter_by(presentation=presentation.id,
                                            slug=decision_slug).first()
        return render_template('decision.html', presentation=presentation,
                               decision=decision)
    return render_template("404.html"), 404


@app.route('/<presentation_slug>/vote/<decision_slug>/<choice_slug>/',
           methods=['GET'])
def web_vote(presentation_slug, decision_slug, choice_slug):
    presentations = Presentation.query.filter_by(slug=presentation_slug)
    if presentations.count() > 0:
        presentation = presentations.first()
        decision = Decision.query.filter_by(presentation=presentation.id,
                                            slug=decision_slug).first()
        if decision:
            votes = redis_db.get(choice_slug)
            return render_template('web_vote.html', decision=decision,
                                   presentation=presentation, votes=votes,
                                   choice=choice_slug)
    return render_template("404.html"), 404


def broadcast_vote_count(key):
    total_votes = 0
    if redis_db.get(key):
        total_votes += int(redis_db.get(key))
    total_votes += len(socketio.rooms['/cyoa'][key])
    socketio.emit('msg', {'div': key, 'val': total_votes},
                  namespace='/cyoa')

 

The above code creates two new routes for displaying voting choices and decisions built within the Wizard interface. These decision pages allow an audience member to vote by clicking one of two buttons on a page and staying on the voting page. Those votes are calculated just like SMS votes as long as the user stays on the page.

An active websocket connection increments the vote counter for that choice in Redis and when a user leaves the page the websocket connection is cleaned up and the Redis value for that choice is decremented. Note however one downside of the web browser-based voting is that the websocket connection may not be immediately recognized as closed. It can take a few seconds before the websocket is cleaned up and the vote counter decremented.

We have a couple more steps to wrap up the application’s browser-voting functionality. Modify the cyoa/websockets.py file by adding the highlighted code shown below.

 


from flask.ext.socketio import emit
from flask.ext.socketio import join_room, leave_room

from . import socketio
from .views import broadcast_vote_count


@socketio.on('connect', namespace='/cyoa')
def ws_connect():
    pass

@socketio.on('disconnect', namespace='/cyoa')
def ws_disconnect():
    pass


@socketio.on('join', namespace='/cyoa')
def on_join(data):
    vote = data['vote']
    join_room(vote)
    broadcast_vote_count(vote)

 

The above code handles the websockets connections and determines the decision chosen by a user.

There’s one small change to the Wizard decisions page that’ll make our lives easier. We want to be able to immediately view a decision after it’s been created. To accomplish this task, edit the cyoa/templates/decisions.html file and update the following single highlighted line.

 


{% extends "base.html" %}

{% block nav %}
    {% include "nav.html" %}
{% endblock %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-10">
            <h1>{{ presentation.name }} Decisions</h1>
            {% if decisions|length == 0 %}
                No web browser voting enabled for 
                <em>{{ presentation.name }}</em>.
                <a href="{{ url_for('wizard_new_decision', pres_id=presentation.id) }}">Add a decision point for this presentation</a>.
            {% else %}
                <table class="table">
                    <thead>
                        <tr>
                            <th>Decision</th>
                            <th>First story path</th>
                            <th>Second story path</th>
                            <th>Delete</th>
                            <th>View choice</th>
                        </tr>
                    </thead>
                    <tbody>
                    {% for d in decisions %}
                        <tr>
                            <td><a href="{{ url_for('wizard_edit_decision', presentation_id=presentation.id, decision_id=d.id) }}">{{ d.slug }}</a></td>
                            <td>{{ d.first_path_slug }}</td>
                            <td>{{ d.second_path_slug }}</td>
                            <td><a href="{{ url_for('wizard_delete_decision', pres_id=presentation.id, decision_id=d.id) }}" class="btn btn-danger">X</a></td>
                            <td><a href="{{ url_for('decision', presentation_slug=presentation.slug, decision_slug=d.slug) }}" target="_blank">{{ d.slug }}</a></td>
                        </tr>
                    {% endfor %}
                    </tbody>
                </table>
            {% endif %}
            <div class="btn-top-margin">
            <a href="{{ url_for('wizard_new_decision', pres_id=presentation.id) }}" class="btn btn-primary">Add decision point</a>
            </div>
        </div>
    </div>
</div>
{% endblock %}

 

The above one line change in decisions.html allows a wizard to view the decision she’s created by opening a new browser window with the decision.

We’re almost there – just one more template file to build! Create one more new template named cyoa/templates/web_vote.html with the following contents.

 

{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-md-6">
            <h2>You've chosen <em>{{ choice }}</em>. Stay on 
                this page until all the votes are counted.</h2>
            <h1><span id="vote-counter"></span> votes for 
                {{ choice }}.</h1>
    </div>
</div>
{% endblock %}


{% block js_body %}
<script type="text/javascript" 
    src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/0.9.16/socket.io.min.js"></script>

<script type="text/javascript">
    $(document).ready(function() {
        namespace = '/cyoa';
        var websocket = io.connect('http://' + document.domain + ':' 
                                  location.port + namespace);

        websocket.emit('join', {'vote': '{{ choice }}'});

        websocket.on('msg', function(msg) {
            var voteCounter = $('#vote-counter').html(msg.val);
        });
    });
</script>
{% endblock %}

 

Let’s give our application a final spin to see the web browser-based voting in action.

Run the development server with the following command as we’ve performed in previous blog posts.

 

(cyoa)$ python manage.py runserver

 

Head to http://localhost:5001/wizard to log in with our wizard account. We’ll create a new presentation and add decisions to it through the new user interface to test it out.

create-new-presentation.png

Add a new presentation based with the following data or based on a presentation you’ve already built.

new-presentation-data.png

Next create a decision point using the new code we wrote in this tutorial. This decision will allow the audience to vote with their web browser. Create a decision like the following and save it.

create-new-decision.png

Click the link in the rightmost column to view the new decision.

decisions-list-view.png

Select one of the two options on the screen and your vote will be tallied along with any other browser and SMS-based votes.

chosen.png

You’re voting for this choice via websocket just by staying on this page! This type of voting is a huge help for when cellular service doesn’t work in a room or you’re giving a presentation internationally where many folks don’t have an active cell phone plan, such as with PyCon 2015 in Canada.

Now we’ve got the ability to vote both with SMS and web browsing in our Choose Your Own Adventure presentations! If there was an issue you ran into along the way that you couldn’t figure out remember that there is a tutorial-step-6 tag that contains the finished code for this blog post.

Wizard Mode Engaged!

Our Wizard Mode is complete. We now have far more control over presentations and a new mechanism for audience voting via web browsers in addition to SMS. With our battle-ready upgrades, we’re set to use them in a live technical talk. In fact, this is the code that Kate Heddleston and I used for the talks Choose Your Own WSGI Deployment Adventure at PyCon 2015.

Let me know what new Choose Your Own Adventure presentation stories you come up with or open a pull request to improve the code base. Contact me via:

  • Email: makai@twilio.com
  • GitHub: Follow makaimc for repository updates
  • Twitter: @mattmakai