Skip to main content
In Local Abilities, main.py can execute functions defined in devkit_functions.py on the OpenHome DevKit hardware, outside of the sandbox environment.
Local Abilities only run on actual OpenHome DevKit hardware. They do not run in the web Live Editor’s simulated environment.

What it unlocks

Running code through devkit_functions.py lets your Ability use features that are not available in the normal sandboxed runtime.
  • Restricted Python libraries — use libraries that are not available inside sandboxed main.py
  • Direct hardware access — control LEDs, GPIO pins, cameras, and connected devices
  • Shell commands — run device-side commands from your DevKit functions
  • Device-level data — read hardware state, temperature, memory, network details, and similar system data
  • Long-running device tasks — run background effects, child processes, or other on-device workflows

How it works

Local Abilities are split across two sides:
  • main.py runs in the normal Ability runtime and handles the voice flow, prompts, conversation, and overall user experience
  • devkit_functions.py runs on the OpenHome DevKit and contains the on-device functions you want to execute
You declare functions inside devkit_functions.py, then call them from main.py. The function runs on the DevKit and the result is returned to main.py. In practice, keep the voice and conversation flow in main.py, and move hardware access, restricted libraries, and device-side operations into devkit_functions.py. The third file, requirements.txt, lists the packages that get installed on the DevKit side for devkit_functions.py to use.

Running functions on the DevKit from main.py

send_devkit_capability_action() is a CapabilityWorker method. Use it in main.py to:
  • Call a function defined in devkit_functions.py and run it on the OpenHome DevKit
  • Pass arguments to that function
  • Set how long to wait for the response
result = await self.capability_worker.send_devkit_capability_action(
    function_name="your_function_name",
    args=["your_arg_1", "your_arg_2"],
    timeout=8,
)
These are the parameters:
ParameterDescription
function_nameName of the function to run from devkit_functions.py
argsList of arguments passed to that function
timeoutNumber of seconds to wait before timing out
capability_nameOptional. Use this only when you want to call a function from another Ability’s devkit_functions.py
Calling a function in main.py from devkit_functions.py:
result = await self.capability_worker.send_devkit_capability_action(
    function_name="neopixel_solid",
    args=["ff0000", "180"],
    timeout=8,
)
You can also call functions from another Ability’s devkit_functions.py by passing that Ability’s name in the optional capability_name parameter. Calling a function in main.py from another Ability’s devkit_functions.py:
result = await self.capability_worker.send_devkit_capability_action(
    function_name="get_sensor_value",
    args=["temperature"],
    timeout=10,
    capability_name="my_new_local",
)
To find the name of another installed Ability to use in capability_name, see Installed Abilities in the Local Abilities section below.

Local Abilities in Live Editor

Selecting the Local category

See Ability Types for the full breakdown. Local is one of the four Ability categories. To create a Local Ability, select Local Ability and choose a template from the templates. If you choose to upload a custom Ability, make sure it includes devkit_functions.py and requirements.txt.

Local Ability category in the dashboard

File structure

Local Abilities use three files that matter:
FileWhat goes here
main.pyVoice flow, prompting, conversation logic, and calls to DevKit-side functions
devkit_functions.pyFunctions that run on the OpenHome DevKit and perform hardware or device-side work
requirements.txtDependencies used by devkit_functions.py on the OpenHome DevKit side
The DevKit-side file must be named exactly devkit_functions.py. No other filename will be picked up by the platform.
Packages listed in requirements.txt are installed for devkit_functions.py. They are not available in the sandboxed runtime where main.py runs.

Advanced DevKit Controls

If your DevKit is online and connected, the Advanced DevKit Controls toggle appears in the Ability Editor. Enable it to expand the Advanced DevKit Controls section.

Advanced DevKit Controls button

Once Advanced DevKit Controls are enabled, scroll down and you will see the Advanced DevKit Controls section. From here you can sync your Ability to the DevKit, restart the Agent, and view the DevKit connection status.

Advanced DevKit Controls and Sync Abilities button

Syncing Local Abilities with the DevKit

Changes made in the Live Editor are applied to the DevKit when you save or sync them. Automatic sync on save Press Ctrl + S or click the Save button in the Live Editor.
  • devkit_functions.py or requirements.txt — the file is pushed to the DevKit and any new dependencies in requirements.txt are installed. You can see the sync status in the Advanced DevKit Controls section while the save is in progress.

Sync capabilities on save

  • main.py — the code is saved to the cloud and synced with the DevKit sandbox, and the Agent is restarted on the DevKit. After that, you can test the latest version of your Ability.
Manual sync via Advanced DevKit Controls You can also sync manually from the Advanced DevKit Controls section.
  • devkit_functions.py or requirements.txt — click Sync Abilities. Once synced, the latest DevKit-side code is live on the DevKit and any new dependencies are installed.
  • main.py — click Sync Abilities, then click Restart Agent. This ensures the Agent is running the latest main.py before you continue testing.

Logging on the DevKit

Use the DevKit logger inside devkit_functions.py to debug on-device behavior. Messages written with this logger appear in the DevKit section of the Ability Editor logs.
from devkit_utils.devkit_logging import web_logger as log

log.info("lights control functions loaded")

def neopixel_solid(color="ff0000", brightness="180"):
    log.info("neopixel_solid called with color=%s brightness=%s", color, brightness)
    # Your LED control code runs here
    log.info("neopixel_solid completed")
To view the logs, open the DevKit section inside the Ability Editor logs after triggering the Ability on the DevKit.

DevKit logs section in the Ability Editor

Installed Abilities

To use functions from another Ability’s devkit_functions.py, you need that Ability’s name to pass in the capability_name parameter. To find it, click the Quick Reference Installed Abilities button in the top-left corner of the Ability Editor.

Quick Reference Installed Abilities button

This opens the installed Abilities list. Copy the name of the Ability whose devkit_functions.py you want to call and pass it in the capability_name parameter.

Installed Abilities list

Example: DevKit LED controller

This is a voice-controlled LED controller. Users say something like “make the ring pulse blue” and the DevKit’s NeoPixel ring responds.

Trigger words

This example can be triggered with phrases like:
  • control the lights
  • lights control
  • lights controller

requirements.txt

rpi-ws281x
gpiozero
picamera2
These packages are installed for the DevKit-side runtime only. They are available inside devkit_functions.py but are not installed in the sandboxed runtime where main.py runs.

main.py — the sandboxed voice flow

import json, re
from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker

NEOPIXEL_COMMANDS = {
    "neopixel_off":    {"description": "Turn all LEDs off",   "args": []},
    "neopixel_solid":  {"description": "Solid hex color fill", "args": ["hex_color", "brightness"]},
    "neopixel_rainbow": {"description": "Rainbow cycle",       "args": ["duration", "speed"]},
    # ... more commands ...
}

SYSTEM_PROMPT = f"""You are a devkit LED controller assistant.
Respond with ONLY JSON: {{"function_name": "...", "args": [...], "spoken_response": "..."}}
Available commands:
{chr(10).join(f"- {n}({', '.join(i['args'])}): {i['description']}" for n, i in NEOPIXEL_COMMANDS.items())}
"""


class LightControllerCapability(MatchingCapability):
    worker: AgentWorker = None
    capability_worker: CapabilityWorker = None

    #{{register capability}}

    async def execute_light_command(self, command_dict: dict):
        function_name = command_dict.get("function_name", "")
        args = command_dict.get("args", [])
        spoken = command_dict.get("spoken_response", "Done!")

        if function_name == "none" or function_name not in NEOPIXEL_COMMANDS:
            await self.capability_worker.speak(spoken)
            return

        result = await self.capability_worker.send_devkit_capability_action(
            function_name, args, 8,
        )

        success = bool(result) and (
            (isinstance(result, dict) and (result.get("success") or not result.get("error")))
            or not isinstance(result, dict)
        )

        if success:
            await self.capability_worker.speak(spoken)
        else:
            await self.capability_worker.speak("Sorry, the command failed.")

    async def first_function(self):
        msg = await self.capability_worker.wait_for_complete_transcription()
        history = []

        while True:
            if msg.strip().lower() in ("stop", "exit", "quit", "done"):
                await self.capability_worker.speak("Lights control off.")
                break

            response = self.capability_worker.text_to_text_response(
                f'User request: "{msg}"', history, system_prompt=SYSTEM_PROMPT,
            )
            cleaned = re.sub(r"^```[a-zA-Z]*\n|\n```$", "", response.strip())
            command_dict = json.loads(cleaned)
            await self.execute_light_command(command_dict)

            await self.capability_worker.speak("What else?")
            msg = await self.capability_worker.user_response()

        self.capability_worker.resume_normal_flow()

    def call(self, worker: AgentWorker):
        self.worker = worker
        self.capability_worker = CapabilityWorker(self)
        self.worker.session_tasks.create(self.first_function())

devkit_functions.py — the on-device hardware side

import os, subprocess, json, time, signal

LED_STRIP_PIXELS = 24
LED_GPIO_PIN = 12
LED_FREQ_HZ = 800000
LED_DMA = 10
LED_CHANNEL = 0


def _hex_to_rgb(hex_str):
    hex_str = str(hex_str).lstrip("#")
    return (int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16))


def _init_strip(brightness=180):
    from rpi_ws281x import PixelStrip, ws as ws_mod
    strip = PixelStrip(
        LED_STRIP_PIXELS, LED_GPIO_PIN, LED_FREQ_HZ, LED_DMA,
        False, int(brightness), LED_CHANNEL,
        strip_type=ws_mod.WS2811_STRIP_GRB,
    )
    strip.begin()
    return strip


def neopixel_off():
    from rpi_ws281x import Color
    strip = _init_strip()
    for i in range(LED_STRIP_PIXELS):
        strip.setPixelColor(i, Color(0, 0, 0))
    strip.show()


def neopixel_solid(color="ff0000", brightness="180"):
    from rpi_ws281x import Color
    strip = _init_strip(brightness=int(brightness))
    r, g, b = _hex_to_rgb(color)
    for i in range(LED_STRIP_PIXELS):
        strip.setPixelColor(i, Color(r, g, b))
    strip.show()


def get_stats():
    temp = open("/sys/class/thermal/thermal_zone0/temp").read().strip()
    disk = subprocess.run("df -h /", shell=True, capture_output=True, text=True).stdout
    print(json.dumps({"temp_c": int(temp) / 1000, "disk": disk}))


FUNCTION_REGISTRY = {
    "neopixel_off":   neopixel_off,
    "neopixel_solid": neopixel_solid,
    "get_stats":      get_stats,
}

The interaction flow

1

User triggers the Ability

Says a trigger phrase such as “lights controller”.
2

main.py asks the user what they want

“What would you like me to do with the lights?”
3

LLM converts natural language to a function call

The user says “make it red” → LLM returns {"function_name": "neopixel_solid", "args": ["ff0000", "180"], "spoken_response": "Making the ring red."}.
4

main.py dispatches to the OpenHome DevKit

await self.capability_worker.send_devkit_capability_action("neopixel_solid", ["ff0000", "180"], 8)
5

devkit_functions.py executes on the DevKit

The neopixel_solid function runs on the DevKit, writes to the GPIO-connected LED strip, and returns the result.
6

Result returns to main.py

main.py receives {"success": true} and speaks the confirmation to the user.
7

Loop or exit

If the user says “stop”, the Ability exits and calls resume_normal_flow().

Best practices

Clean separation makes both sides easier to debug. Keep devkit_functions.py focused: do the hardware work and return the result.
Hardware calls can block. A 5–10 second timeout is typical for lightweight actions; bump to 30 or more for long-running effects or captures.
Use the DevKit logger web_logger for debugging and inspect messages in the DevKit logs section inside the Ability Editor.
Packages listed there are installed for devkit_functions.py. They are not available in the sandboxed runtime where main.py runs.
Not every DevKit has every peripheral. Wrap hardware initialization in try/except and return an informative error instead of crashing — your Ability can still speak a helpful message to the user.

See also