alexa

A smart home skill is a router and a contract

A smart home skill is a router and a contract

Four vendors live in my flat and every one of them shipped an app. The bulbs have one, the thermostat has another, the door lock has a third, and none of them can be persuaded that the others exist. The obvious fix is to put one thing in front of all of them, and since I already talk to a speaker in the hallway, that one thing may as well be Alexa.

The result is a single Lambda function. Everything below is from home-automation, which is a weekend project and reads like one.

What actually arrives

A smart home skill is not a conversation. Alexa does the language part and sends your function a directive: a small JSON document with a namespace, a name, and the endpoint it applies to.

{
  "directive": {
    "header": {
      "namespace": "Alexa.PowerController",
      "name": "TurnOn",
      "payloadVersion": "3",
      "correlationToken": "..."
    },
    "endpoint": {
      "scope": { "type": "BearerToken", "token": "..." },
      "endpointId": "kitchen-ceiling"
    },
    "payload": {}
  }
}

That is the whole interface. Alexa.BrightnessController / SetBrightness carries a number in the payload, Alexa.Discovery / Discover has no endpoint at all, and Alexa / ReportState wants the current state back. Perhaps twenty pairs in total for a flat like mine.

The router half

Twenty pairs is exactly the size at which a dispatch chain starts to rot. My first version was a chain of if statements in the handler, and by the third capability it was the only file I ever touched, which is the usual sign that a seam is in the wrong place.

So the pairs live in a table and each module puts itself in it:

_HANDLERS = {}


def register(namespace, name):
    def decorator(function):
        _HANDLERS[(namespace, name)] = function
        return function

    return decorator


def resolve(namespace, name):
    try:
        return _HANDLERS[(namespace, name)]
    except KeyError:
        raise errors.UnsupportedOperation(
            "no handler for {0}.{1}".format(namespace, name)
        )

A capability is then a module and nothing else:

@routing.register("Alexa.PowerController", "TurnOn")
def turn_on(directive):
    return _set_power(directive, ON)


@routing.register("Alexa.PowerController", "TurnOff")
def turn_off(directive):
    return _set_power(directive, OFF)

The handler imports the package for the side effect, resolves, and calls. It knows nothing about power or brightness or locks, and adding a capability touches one new file plus one import line.

The contract half, which is the one that hurts

Here is what took me two evenings to understand. Alexa does not discover what your function can do by trying things. It asks once, during discovery, and you answer with a capability document per endpoint:

def _interface(namespace, properties):
    return {
        "type": "AlexaInterface",
        "interface": namespace,
        "version": "3",
        "properties": {
            "supported": [{"name": name} for name in properties],
            "retrievable": True,
            "proactivelyReported": True,
        },
    }

Whatever you claim there, Alexa believes. Claim BrightnessController on a bulb that only switches and the utterance will be understood, routed, and sent to you — and your handler will raise, and the app will say the device is not responding. Which is true, and utterly unhelpful, because the mistake was made weeks earlier in a different file.

The reverse is worse in its own way: implement a directive and forget to advertise it, and the utterance never reaches you at all. There is no error anywhere. Alexa simply says she does not know how, and you go looking through CloudWatch for a request that was never made.

So the capability list and the handler table are two halves of the same thing and have to be edited together. I have not found a way to make the code enforce that, and I have started to suspect the honest answer is a test that walks both tables and diffs them. For now it is a line in the README and the memory of those two evenings.

The bit I would tell myself in January

The device model matters more than the skill. Once every bulb, lock and sensor in the flat is described by the same handful of fields — an id, a type, a room, and how to reach it — the skill collapses into a translation layer over that model. It is about two hundred lines. The interesting code is all underneath.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev