Skip to content
Console

Writing a Skill

A skill is the part of your assistant that answers one kind of question. This page takes you from an empty folder to a working skill, and explains the few decisions you have to make along the way.

thalovant-skillkit carries the plumbing every skill needs, so what you write is the answer and nothing else.

Confirm:

  • Python 3.11 or newer;
  • a hub you can install skills onto;
  • a name for your skill, in the form thalovant-skill-<something>.
Terminal window
pip install thalovant-skillkit

One command writes a complete skill that already passes its own checks and tests:

Terminal window
thalovant-skillkit new garden-watering
cd thalovant-skill-garden-watering
pip install -e ".[test]"
thalovant-skillkit check && pytest

You get this:

thalovant-skill-garden-watering/
├── thalovant_skill_garden_watering/
│ ├── __init__.py the skill
│ ├── version.py
│ └── locale/
│ ├── supported.json which languages you ship
│ ├── en-US/
│ │ ├── vocab/GardenWateringKeyword.voc
│ │ ├── dialog/garden.watering.dialog
│ │ └── skill.json
│ └── fr-FR/ the same files, in French
├── test/
│ ├── test_skill.py hears its keyword, ignores the rest
│ └── test_contract.py locales complete, packaging sound
├── pyproject.toml entry point and package data, declared
└── .github/workflows/test.yml runs check and pytest on every push

locale/ is found for you. You never write a path to it.

  1. Say what your skill answers.

    A .voc file is a list of words or phrases, one per line. locale/en-US/vocab/GardenWateringKeyword.voc:

    garden watering
    water the garden
    sprinklers
  2. Say how it replies.

    A .dialog file is a list of possible replies, one per line. One is chosen at random, so your assistant does not repeat itself. locale/en-US/dialog/garden.watering.dialog:

    The garden was watered this morning.
    The sprinklers ran at six.
  3. Write the answer.

    thalovant_skill_garden_watering/__init__.py:

    from thalovant_skillkit.skill import ThalovantFallbackSkill
    class GardenWateringSkill(ThalovantFallbackSkill):
    FALLBACK_PRIORITY = 98
    def can_answer(self, message) -> bool:
    return self.mentions(self.utterance(message), "GardenWateringKeyword")
    def reply(self, utterance, lang, context):
    return self.dialog("garden.watering", lang)

reply is the one method most skills need. What it returns is spoken on the hub and shown in the showroom preview, so the two can never disagree. can_answer runs for everything anyone says, so keep it cheap and narrow.

Intent skill

Answers phrasings you list in an .intent file. Use ThalovantSkill and OVOS’s @intent_handler. Best when you know what people will say.

Fallback skill

Answers what no intent claimed. Use ThalovantFallbackSkill with can_answer and handle_fallback. Best for open questions.

A skill can be both: give it intents and a fallback for what they miss.

This is the one number worth thinking about. The hub asks fallback skills in order and stops at the first one that says yes, so a low number means “ask me before the others”.

Priority Meaning
91–95 I answer one specific thing
96–98 I answer a topic
99–100 I will answer almost anything

An operator can move your skill with a fallback_priority setting, but only within 91–100 — a number outside that band is refused rather than quietly clamped.

self.utterance(msg) what was said
self.lang_of(msg) the language of this utterance
self.location_of(msg) where the house is
self.mentions(text, "Voc") does the text mention this vocabulary
self.mentioned_term(text, "Voc") which term it mentioned
self.dialog("name", lang) a line from locale/<lang>/dialog/name.dialog
self.setting("key", default) one skill setting
self.locale_resources the whole locale/ tree, if you need it directly

Everything on the OVOS skill class still works: self.speak, self.speak_dialog, self.voc_match, self.settings, and the intent decorators.

Copy the folder and translate the files:

locale/
├── en-US/vocab/NewsKeyword.voc
└── fr-FR/vocab/NewsKeyword.voc

Nothing in your code changes. self.lang_of(message) gives you the language of each utterance, and self.dialog and self.mentions use it. A language with no folder falls back to en-US, so a half-finished translation answers in English rather than going silent.

The scaffold wrote four tests. Keep them and add yours:

from thalovant_skillkit.testing import MONTREAL, message
from thalovant_skill_garden_watering import GardenWateringSkill
def test_it_hears_its_keyword():
assert GardenWateringSkill().can_answer(message("water the garden"))
def test_it_hears_it_in_french():
assert GardenWateringSkill().can_answer(message("arrose le jardin", lang="fr-FR"))
def test_it_ignores_what_is_not_its_business():
assert not GardenWateringSkill().can_answer(message("set a timer for ten minutes"))
def test_it_has_something_to_say():
assert GardenWateringSkill().preview_reply("water the garden", "en-US")

message() builds what a satellite really sends, including the language. Pass location=MONTREAL when your skill reports a time, a date or the weather — without a location the assistant answers from its default, which is Kansas.

Terminal window
thalovant-skillkit check

This is what your CI runs. It confirms every locale in supported.json carries every file en-US has with the same placeholders, that JSON parses and regexes compile, that your entry point names a class that exists, that locale/ will be in the installed package, and that your priority is in band. Each problem is one line saying which file and what is wrong.

The scaffold already declared the entry point and the package data in pyproject.toml:

[project.entry-points."opm.skill"]
"thalovant-skill-garden-watering.thalovant" = "thalovant_skill_garden_watering:GardenWateringSkill"
[tool.setuptools.package-data]
thalovant_skill_garden_watering = ["locale/*.json", "locale/*/*", "locale/*/*/*"]

The first line is how a hub finds your skill. The second is how your locale/ tree reaches the installed package — leave it out and every reply becomes a dialog file’s name. thalovant-skillkit check fails on both mistakes.

  • can_answer returns true for what your skill handles and false for the rest;
  • your tests pass, including one for a language you support and one for a question you do not handle;
  • the hub answers when you ask out loud.
What you see What it usually is
The reply is the dialog file’s name The .dialog file is missing, or package_data left locale/ out of the install
Answers in English when asked in French No fr-FR folder, so it fell back — add the translation
Another skill answers instead Something broader sits lower in the ladder; raise your priority or narrow its claim
Your skill answers things it should not can_answer is too broad — check what mentions is matching
Nothing answers at all The entry point name does not match the class, or the package is not installed on the hub