Create an Alexa Skill

Personal assistants are the new trend. I though it was time to build something for Alexa.

Create an Alexa Skill

During the Amazon Prime Days, I bought an Amazon Dot with a 50% discount!
I thought it was time for me to create my first Skill (i.e. vocal app) for Alexa.

Amazon Dot

Note: In this article, I will not share all my source code, for security reasons, as I'm using some personal API/servers

My goal was to create an vocal version of my Android app, ParisGo, which helps parisiens to get the real time schedules of subways, buses, tramways, trains... in a very simple app.

ParisGo

This app is in French, but I will explain it in English :)

To create your skill, create a developer account on the Alexa console website.

The Alexa documentation is quite well written.

Build the Skill

An Alexa Skill like an app, but for Alexa.

skills-overview

When you create a Skill, you will associate some Intents to it.
Each Intent will react to user Utterances.
To each utterance, you can add Slots to get the user parameters to the request.
Each Slot will match to a Slot Type, which can be a custom list, or an Amazon predefined type (date, number, pone, country, movie...) (see here).

Example:
intent-slot-types

Utterances for SubwayNextStop Intent (translated in English) :

When is the next metro station {{SubwayStop}}
What time is the next metro station {{SubwayStop}}
What are the next metro stations {{SubwayStop}}

{{SubwayStop}} will be my Slot, and is linked to the SubwayStop slot type (I used the same name for simplicity reasons).

skill utterances

And my slot type contains all subway stops, with an unique key and a value.
I created some CSV files to easily import those stops.

stop-type

For each slot, you can also specify if it's a mandatory slot (and how to ask the user), or if the value must be confirmed by the user (e.g. for an order).

After that, you will have to specify an Endpoint, which will receive every requests and respond to the user.

You have two choices:

  • Create a custom HTTPS endpoint (POST) with your own server / resources, which will receive and respond in JSON
  • Use an AWS lambda, which is a little function hosted by Amazon in NodeJs or Python. Amazon provides a library to help you parse the query / respond to the user.

For simplicity, I chose to use the AWS lambda service (in NodeJs), to format a request to one of my API and respond in French.

Build the Endpoint

Note: This is not an example of beautiful code, it's just to help you create a simple lambda function

Warning: AWS have a lot of datacenter in the whole world, but some of them are not yet compatible with the Alexa service. Be careful to look in the Alexa documentation and find which regions are compatible with Alexa Skills (I chose Irland in Europe).

To build an AWS Lambda, create an AWS account and go to the Lambda service page.

I chose to base my lambda on the "alexa-skills-kit-nodejs-factskill" (GitHub) example. To create your lambda based on the sample, please follow the wiki instructions.

The wiki is well written, and explains all the steps to make it work.
Still, the following sections can help you...

Check the "Design" configuration section

I had to add the "Alexa Skill Kit" as a lambda input. I advise you to add the skill ID authorized to call your lambda.

lambda-design-1

Update/Add dependencies

The default NodeJs dependencies are quite old. Update them !

More over, to add dependencies, you cannot just update the package.json file, you have to update the node_js folder with the dependencies.

Sadly, you cannot do that with the embedded editor, you will have to download your project as a ZIP file, edit your code and upload it again as a ZIP.

Once that's done, you will not be able to use the embedded editor anymore.

You can also use the AWS CLI, but I didn't bother for this small project.

Monitor your Lambda

When you test your Skill, you can follow the calls and see the logs into the "Monitoring" tab (button "See logs in CloudWatch"). It's helping a lot to understand the behavior of your skill.

lambda-monitoring

Use Dialog Directives if applicable

In some contexts, you NEED some informations from the user, or some explicit confirmations.
To do that, you have to activate some 'Dialog' in the intent / skills / slots with the Alexa console. But your lambda must also be able to manage them.
You can read the Alexa documentation to understand each type of Dialog Directive.

In the following example, I built two intent handler to manage the steps of a Delegate Directive.

Example of source code

package.json

{
  "name": "alexa-sample",
  "version": "1.0.0",
  "description": "Next schedules with Alexa",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "alexa",
    "skill",
    "fact"
  ],
  "author": "IoT-Experiments.com",
  "dependencies": {
    "ask-sdk": "^2.0.0",
    "lodash": "^4.17.10",
    "request": "^2.87.0"
  }
}

index.js

/* eslint-disable  func-names */
/* eslint-disable  no-console */

const Alexa = require('ask-sdk');
const request = require('request');
const _ = require('lodash');

// TODO: change the base URL
const BASE_URL = 'https://api.domain.com/v1/';

const intentTypes = [ 'SubwayNextStop', 'BusNextStop', 'NoctilienNextStop', 'TramwayNextStop' ];

const LaunchRequestHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'LaunchRequest';
  },
  handle(handlerInput) {
    return handlerInput.responseBuilder
      .speak("C'est parti pour une recherche d'horaire. De quelle station souhaitez-vous avoir les prochains passages ?")
      .reprompt("Dites-moi de quelle station souhaitez-vous avoir les prochains passages ?")
      .getResponse();
  }
};

const InProgressNextStopHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'IntentRequest'
        && intentTypes.indexOf(req.intent.name) >= 0
        && req.dialogState !== 'COMPLETED';
  },
  handle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return handlerInput.responseBuilder
      .addDelegateDirective(req.intent)
      .getResponse();
  }
};

const CompletedNextStopHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'LaunchRequest'
      || (req.type === 'IntentRequest'
        && intentTypes.indexOf(req.intent.name) >= 0);
  },
  handle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    // All slots values
    const filledSlots = req.intent.slots;
    // Parse slots values to create an array
    const slotValues = getSlotValues(filledSlots);
    // Get slot value (key and text, as in slot type definition) based on the intent name
    const stopValue = slotValues[req.intent.name.replace('Next', '')];
    // Get the ID used by the webservice
    const originStopKey = stopValue.resolvedId;

    return new Promise((resolve, reject) => {
        request({
          method: 'GET',
          baseUrl: BASE_URL,
          uri: `${originStopKey}/schedules`,
          json: true
        }, function (err, response, body) {
          if(err) {
            reject(err);
          }
          resolve(body);
        })
      })
      .then(body => {
        // TODO : use the body to generate the 'speechOutput'
        let speechOutput = 'TODO';
        return handlerInput.responseBuilder
          .speak(speechOutput)
          .withSimpleCard('Schedules', speechOutput)
          .getResponse();
      });
  },
};

const HelpHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'IntentRequest'
      && req.intent.name === 'AMAZON.HelpIntent';
  },
  handle(handlerInput) {
    return handlerInput.responseBuilder
      .speak("Ask me to look for the subways or buses schedules")
      .reprompt('How can I help you?')
      .getResponse();
  },
};

const ExitHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'IntentRequest'
      && (req.intent.name === 'AMAZON.CancelIntent'
        || req.intent.name === 'AMAZON.StopIntent');
  },
  handle(handlerInput) {
    return handlerInput.responseBuilder
      .speak('A la prochaine !')
      .getResponse();
  },
};

const SessionEndedRequestHandler = {
  canHandle(handlerInput) {
    const req = handlerInput.requestEnvelope.request;
    return req.type === 'SessionEndedRequest';
  },
  handle(handlerInput) {
    console.log(`Session ended with reason: ${handlerInput.requestEnvelope.request.reason}`);

    return handlerInput.responseBuilder.getResponse();
  },
};

const ErrorHandler = {
  canHandle() {
    return true;
  },
  handle(handlerInput, error) {
    console.log(`Error handled: ${error.message}`);

    return handlerInput.responseBuilder
      .speak('Désolé, une erreur est survenue.')
      .reprompt('Désolé, une erreur est survenue.')
      .getResponse();
  },
};

// From 'https://developer.amazon.com/fr/blogs/alexa/post/44dd83f4-4842-40c5-91f8-3868b9f4608c/using-dialog-management-to-capture-a-and-b-or-c-slots'
function getSlotValues(filledSlots) {
  const slotValues = {};
  
  console.log(`The filled slots: ${JSON.stringify(filledSlots)}`);
  Object.keys(filledSlots).forEach((item) => {
    const name = filledSlots[item].name;

    if (filledSlots[item] &&
      filledSlots[item].resolutions &&
      filledSlots[item].resolutions.resolutionsPerAuthority[0] &&
      filledSlots[item].resolutions.resolutionsPerAuthority[0].status &&
      filledSlots[item].resolutions.resolutionsPerAuthority[0].status.code) {
      switch (filledSlots[item].resolutions.resolutionsPerAuthority[0].status.code) {
        case 'ER_SUCCESS_MATCH':
          slotValues[name] = {
            synonym: filledSlots[item].value,
            resolved: filledSlots[item].resolutions.resolutionsPerAuthority[0].values[0].value.name,
            resolvedId: filledSlots[item].resolutions.resolutionsPerAuthority[0].values[0].value.id,
            isValidated: true,
          };
          break;
        case 'ER_SUCCESS_NO_MATCH':
          slotValues[name] = {
            synonym: filledSlots[item].value,
            resolved: filledSlots[item].value,
            isValidated: false,
          };
          break;
        default:
          break;
      }
    } else {
      slotValues[name] = {
        synonym: filledSlots[item].value,
        resolved: filledSlots[item].value,
        isValidated: false,
      };
    }
  }, this);

  return slotValues;
}

const skillBuilder = Alexa.SkillBuilders.custom();

// Each registered Handler will be tested in the order with canHandle()
// if canHandle() returns 'true', the 'handle()' method of the corresponding handler
// will be used to respond to the user request
exports.handler = skillBuilder
  .addRequestHandlers(
    LaunchRequestHandler,
    InProgressNextStopHandler,
    CompletedNextStopHandler,
    HelpHandler,
    ExitHandler,
    SessionEndedRequestHandler
  )
  .addErrorHandlers(ErrorHandler)
  .lambda();

Test your skill

Once the endpoint hosted, and selected into Skill (Alexa console), you can build your Skill with the "Build" button.

Once built, you can test it under the "Test" tab, by activating the "Test is enabled for this skill" toggle.

alexa-skill-test

Deploy it!

To deploy it, you just have to fill all the forms in the 'Distribution', and ask for a certification!
It takes few days to Amazon to reject or approve your Skill.

parisgo-alexa-skill-1