Skip to content
Console
Try one

    Writing a Skill

    Start a Thalovant skill with SkillKit, then find practical help with language, conversations, tests, packaging, and installation.

    A skill is a small program that gives your assistant one job. It might tell a joke, answer a question, or run a quiz.

    New to writing skills? Follow Build an Educational Quiz. You’ll build one working skill in three short lessons, each with a result to check. What SkillKit Gives You helps you choose a helper by task.

    This page is your handbook. Start a different project below, or use the contents list to find help with a particular feature.

    thalovant-skillkit provides resource lookup, message helpers, checks, and optional tools for conversation state and integration testing. OVOS continues to own intent routing, speech, session activation, and scheduled events.

    Use the SkillKit API reference for full helper signatures and return values.

    Use Python 3.11 or newer and a terminal. You can build and test locally without a hub; use a hub for the final spoken check.

    Create a virtual environment in your development directory. These shell commands are for Linux and macOS:

    Terminal window
    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install --pre "thalovant-skillkit==0.24.2"

    SkillKit supports Python 3.10 and newer; the project it generates for you declares Python 3.11 as its minimum. On Windows, activate with .venv\Scripts\Activate.ps1 in PowerShell.

    These examples use SkillKit 0.24.2. The --pre option allows the OVOS prereleases used by its testing tools.

    Testing an older OVOS version

    If you deliberately test Workshop 8, install ovos-workshop==8.0.0 and setuptools<81 together in a separate environment: its older plugin manager imports pkg_resources, which newer setuptools removed. Current OVOS doesn’t need that legacy constraint.

    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
    python -m pip install --pre -e ".[test]"
    thalovant-skillkit check
    python -m pytest

    The files you will work with are:

    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/ starter resources to translate
    ├── 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

    The result is a fallback skill with four behavior tests and one source-contract test. Run the remaining commands from this generated project directory. The base class finds locale/ beside your skill module.

    Check your result: pytest reports five passing tests. You have created a program and checked its first answers. It isn’t connected to a speaker yet.

    Paths beginning with locale/ below are relative to thalovant_skill_garden_watering/, the import-package directory.

    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. Random selection can repeat a line. locale/en-US/dialog/garden.watering.dialog:

      This garden skill is a demo; it has no watering schedule yet.
      I can hear garden questions, but I have no watering data yet.
    3. Write the answer.

      thalovant_skill_garden_watering/__init__.py:

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

    This example answers questions about a topic and speaks demo text; it doesn’t control sprinklers or query a service. Replace the demo with your own answer logic when ready.

    The fallback base speaks a nonempty reply result and returns True; an empty result returns False so fallback handling can continue. preview_reply calls the same answer logic without emitting speech. Separate calls can select different dialog lines. Keep reply free of bus side effects so it works in both contexts.

    QUESTIONS_ONLY = True and claims() keep ordinary room chatter from triggering this question skill. For a command such as “water the garden,” use a defined intent instead.

    can_answer is consulted during fallback selection, so keep it cheap and narrow. If you override initialize, call super().initialize() to retain fallback registration.

    Your interactionBase and implementation
    A defined command, such as “check the garden”ThalovantSkill, an OVOS @intent_handler, and an .intent resource. The handler explicitly speaks or performs the requested action.
    A topic that earlier pipelines did not handleThalovantFallbackSkill with can_answer and reply, as above. Override handle_fallback only when returning spoken text is insufficient.
    A follow-up that depends on a previous turnThalovantConversationalSkill with an initial handler, can_converse, and converse. Activation and routing stay with OVOS.
    Media search and playback through OCPThalovantCommonPlaySkill when the optional OVOS common-play skill base is installed. It is unavailable (None) without that dependency.

    Intent handlers don’t automatically call reply. For example, an intent skill explicitly calls self.speak_dialog("garden.watering") inside its decorated handler. A fallback skill can also register intents for its specific commands.

    A skill that both answers a topic and asks follow-up questions can use ThalovantConversationalFallbackSkill. For media with follow-up turns, use ThalovantConversationalCommonPlaySkill. SkillKit composes the native OVOS classes for you, including their conversation registration.

    Use Keep Your Skill Responsive when adding a service, a sound library, background refresh, or optional saved state. The guide shows which helper to choose, how to check the result, and when caches refresh. News uses the shared network and worker helpers; Fart uses shared sound inventory and varied-dialog helpers; Alarm, Timer, Reminder and Stopwatch share persistence. Your skill keeps the interesting part: what it does and what it says.

    Within the low fallback band, lower numbers are tried earlier. Use this convention for Thalovant skills:

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

    An operator can override fallback_priority with an integer from 91 through 100. Invalid or out-of-band settings use the class default; they don’t raise a configuration error. The source check flags an out-of-band class default.

    self.utterance(msg)what was said
    self.lang_of(msg)the language of this utterance
    self.location_of(msg)the location dictionary in the message, or None
    self.mentions(text, "Voc", lang)whether the text mentions this vocabulary
    self.mentioned_term(text, "Voc", lang)the matching term, or an empty string
    self.dialog("name", lang)a line from locale/<lang>/dialog/name.dialog
    self.speak_to(msg, text)say it to the room that asked, in its language
    self.setting("key", default)one skill setting
    self.locale_resourcesthe 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. Use self.speak_to when the reply must reach the message you were given: converse(), a stop hook, or a hub answering two rooms in two languages. self.speak finds a message by walking the call stack and speaks in the skill’s own language. Prefer self.speak_to(message, text) for replies to incoming commands so a multilingual request keeps its own language.

    An .intent file describes a command, one phrase per line. Start with complete sentences, such as play pocket quiz. Add templates only when they make the file easier to read:

    [start|begin] pocket quiz
    (play|start) pocket quiz

    Parentheses require one alternative. Square brackets make the whole part optional, so the first line also accepts pocket quiz. Put alternatives inside brackets or parentheses; start|begin pocket quiz is not a valid template. <QuizName> inserts entries from vocab/QuizName.voc, while {topic} is a slot whose value the intent engine supplies to your handler. Keep slot names the same in every translation. thalovant-skillkit check expands templates using OVOS’s rules and reports malformed lines.

    locale_resources.matches_literal_intent(text, name, lang) is narrower: it matches complete literal lines, ignoring case, accents and punctuation. It does not expand templates or extract slots. Use it for fixed commands in a conversation; let the intent engine handle templates. The quiz keeps literal phrases so its text replay and voice handler recognise the same commands.

    A .voc file holds words or OVOS patterns, not arbitrary records. If your code reads rows such as blue|navy|sky blue, put them in locale/en-US/tables/colors.table and load them with self.locale_resources.combined_lines(lang, "tables", "colors.table"). This keeps custom table data out of OVOS’s vocabulary parser.

    A recogniser may send several guesses. utterance(message) gives the first; utterances(message) gives all available alternatives without duplicates:

    from thalovant_skillkit.message import utterances
    for candidate in utterances(message):
    if self.locale_resources.matches_literal_intent(candidate, "pocket_quiz.start", lang):
    return self._say(message, "start")

    Here lang is the incoming message’s language, and _say is the quiz’s response method. Accept the first valid command, then stop looking. A later alternative must never cause a second answer or a second score change.

    Keep the words in a normal .dialog file. Beside it, add an optional .ssml file with the same name, words, placeholders, and number of nonempty lines. SSML is a way to give a voice small instructions, such as “pause here”.

    locale/en-US/dialog/ready.dialog:

    Ready, {name}? Let's play.

    locale/en-US/dialog/ready.ssml:

    Ready, {name}? <break time="250ms"/> Let's play.

    Speak both forms through SkillKit:

    text = self.dialog("ready", self.lang_of(message), {"name": "Sam"})
    self.speak_to(message, text)

    Expected result: the displayed text stays Ready, Sam? Let's play. A voice that supports SSML can pause for a quarter of a second after the question. Voices without that support still receive the plain words. The reply keeps the caller’s language and session.

    The file extension is .ssml, not .ssml.dialog. Translate each twin alongside its plain dialog; SkillKit does not borrow a twin from another locale. Keep twin lines literal apart from placeholders: random (alternatives|like these) or [optional words] make speak_dialog use the ordinary OVOS renderer without the twin. self.dialog does not expand those patterns. Run thalovant-skillkit check to catch mismatched words or placeholders.

    For a sentence composed in Python, use the helpers instead of building XML:

    from thalovant_skillkit import ssml
    text = ssml.say("Your code is", ssml.digits("204"), ssml.pause("250ms"), "Ready?")
    self.speak_to(message, text)

    Other helpers include spell, telephone, foreign, emphasis, and sub. They return Speech, a string with plain text and optional markup. ssml.say and + preserve both forms; f-strings and ordinary string formatting lose the markup. Pass user or service text as values, never as an SSML template: SkillKit escapes those values. SkillResources.dialog is a plain-text reader; use self.dialog on a skill when you want its speech twin too.

    Translate the scaffold’s French resources, including its initial keyword. For an additional language, copy a complete locale folder, translate its resources, and add the language tag to locale/supported.json:

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

    For the French tests below, include arroses le jardin in fr-FR/vocab/GardenWateringKeyword.voc. Put Cette compétence de jardin est une démo sans données d’arrosage. in fr-FR/dialog/garden.watering.dialog. The scaffold’s English keyword in the French folder is a placeholder to replace.

    Pass self.lang_of(message) explicitly to resource helpers, as the example does. Without that argument, they use the skill’s language, which can differ from this speaker’s language.

    You don’t need to copy English files into en-CA, en-GB, and en-AU. SkillKit can use the existing en-US translation for those speakers. The same applies to other languages: fr-BE can use fr-FR, and pt-AO can use pt-PT.

    Think of a regional folder as a small set of replacements. SkillKit looks for an exact regional match first, then a parent tag, then the language’s reference translation and compatible regional translations. Only then does it try the configured default, normally en-US. An existing dialog supplies its own lines; a missing or empty file continues along that chain. Vocabulary matching includes compatible regional terms before trying the default language.

    With the French files from the previous step, add this test to test/test_skill.py, which already imports GardenWateringSkill:

    from thalovant_skillkit.testing import message
    def test_canadian_french_uses_french_resources():
    skill = GardenWateringSkill()
    request = message("quand est-ce que tu arroses le jardin", lang="fr-CA")
    lang = skill.lang_of(request)
    assert lang == "fr-CA"
    assert skill.locale_resources.lang(lang) == "fr-FR"
    assert skill.dialog("garden.watering", lang) == (
    "Cette compétence de jardin est une démo sans données d’arrosage."
    )

    Expected result: the Canadian French request reads the French reply, while its language stays fr-CA. Keep passing that original language to speech and services; the resource folder’s name doesn’t choose the person’s accent, units, location, or timezone.

    If Canadian French needs different wording, add only locale/fr-CA/dialog/garden.watering.dialog. That file now wins for fr-CA; other missing French resources still come from fr-FR. List complete translations in locale/supported.json, which drives completeness checks. Partial regional overrides can remain unlisted; runtime lookup uses the bundled directories.

    Fallback reuses a translation; it doesn’t create one. A different writing system needs suitable resources: Simplified Chinese alone doesn’t provide Traditional Chinese support. Recognition and voice availability also depend on the hub’s ASR and TTS plugins. Test regional sessions in OVOS as well as these resource tests.

    Use SkillKit 0.15.0 or later when a regional translation must also work with native OVOS intents or code that reads files directly. Keep the common words in the original language folder and write only the differences in locale/regional.json inside your skill package:

    {
    "version": 1,
    "locales": {
    "fr-CA": {
    "source": "fr-FR",
    "overrides": {
    "dialog/garden.watering.dialog": "Cette démo peut parler de la fin de semaine.\n"
    }
    },
    "en-CA": {"source": "en-US", "overrides": {}}
    }
    }

    Each override key is a file path relative to its locale folder. Its value is the complete file text, including \n between lines. Keep placeholders such as {score}, JSON keys, custom table keys before |, and regex group names unchanged. This command copies shared text; it doesn’t translate it for you.

    From the skill’s repository, run:

    Terminal window
    thalovant-skillkit locales --write
    thalovant-skillkit check --no-fleet
    python -m pytest -q

    Expected result: fr-CA contains the Canadian reply plus all the other French resources. en-CA contains the shared English resources. Both tags appear in supported.json, and the checker reports that the skill keeps its contracts. The earlier fallback test now selects fr-CA and expects the new Canadian reply; update those two assertions after generating this example.

    Commit the manifest, generated folders, and updated supported.json. Future edits go in the source language or the manifest, then run locales --write again. Don’t edit generated files directly: ordinary check reports stale or manually changed files. thalovant-skillkit locales performs the same freshness check without writing. If a source file is removed, the tool names obsolete generated files for you to remove explicitly; it never silently deletes them.

    Sources must be existing base locales in the same language. Chaining one generated region to another isn’t supported. A regional override must name an existing source file and preserve its placeholders. To add a new resource, put it in the base locale first. Full validation still belongs to check and your tests. Generation happens during development, with no network calls or extra work when someone speaks. Generated files are ordinary package data and need the same wheel checks as other translations.

    Start with common regional variants of languages your skill already supports:

    Shared translationCommon regional targets
    en-USen-CA, en-GB, en-AU, en-NZ
    fr-FRfr-CA, fr-BE, fr-CH
    es-ESes-MX, es-AR, es-CO, es-US
    de-DEde-AT, de-CH
    pt-PTpt-AO, pt-MZ
    nl-NL, sv-SEnl-BE, sv-FI, respectively
    zh-CNzh-SG; zh-TW needs Traditional Chinese wording

    Keep existing regional translations where they already fit. Identical wording can be inherited honestly; separate folders don’t mean separate native-speaker reviews. Regional English spelling, Canadian French expressions, Swiss German orthography and Argentine Spanish grammar need contextual review. For Chinese, OpenCC’s Taiwan configuration can help prepare Traditional characters and common Taiwan terms. Check spoken phrases and regex literals too; conversion doesn’t create a Cantonese translation. Ask fluent speakers to review important flows before claiming linguistic quality.

    Shared language rules come from thalovant-languages. Use 0.3.1 or later for Traditional Chinese rule lookups (zh-TW, zh-HK, zh-MO, zh-Hant) and question detection in Chinese text without spaces:

    from thalovant_languages import asks
    assert asks("什麼時候出發", "zh-TW") # “When do we leave?” — no question mark needed.

    SkillKit handles your skill’s translated dialogs; thalovant-languages supplies shared word lists and grammar rules. You don’t need to copy these rules into each regional folder. Chinese script conversion happens when maintainers build the data, so skill users don’t need OpenCC at runtime.

    Terminal window
    python -m pip install --upgrade "thalovant-languages>=0.3.1"
    thalovant-languages check

    The check should report no data errors. Releases reach PyPI first; piwheels may follow later.

    A fallback that publishes concrete .intent examples can accept those complete phrases before checking keywords:

    def can_answer(self, message):
    text, lang = self.utterance(message), self.lang_of(message)
    return self.locale_resources.matches_literal_intent(
    text, "garden.watering", lang
    ) or (self.claims(text, lang) and self.mentions(text, "GardenWateringKeyword", lang))

    Use the name of an actual intent resource in your skill. This helper normalizes case, accents, punctuation and spaces, reads compatible regional resources, and skips lines containing slots or alternative patterns. It doesn’t replace the intent engine. Test every declared locale’s example phrases and verify that unrelated commands still yield to other skills.

    Keep Follow-Ups With The Right Conversation

    Section titled “Keep Follow-Ups With The Right Conversation”

    Use ThalovantConversationalSkill when a short answer such as “again” depends on an earlier turn. Create a SessionStateStore in the skill instance to hold that context:

    from thalovant_skillkit import session_of
    from thalovant_skillkit.sessions import SessionStateStore
    from thalovant_skillkit.skill import ThalovantConversationalSkill
    class GardenConversationSkill(ThalovantConversationalSkill):
    def __init__(self, *args, **kwargs):
    self.topics: SessionStateStore[str] = SessionStateStore(
    max_entries=128, default_ttl=90
    )
    super().__init__(*args, **kwargs)
    def remember_topic(self, message, topic):
    session_id = session_of(message).get("session_id")
    if isinstance(session_id, str) and session_id:
    self.topics.set(session_id, topic, ttl=90)
    def can_stop(self, message):
    session_id = session_of(message).get("session_id")
    return (
    isinstance(session_id, str)
    and bool(session_id)
    and session_id in self.topics
    )
    def stop_session(self, session):
    self.topics.remove(session.session_id)
    # OVOS falls through to global stop() if this returns False.
    return True
    def stop(self):
    self.topics.clear()
    return True

    This is a state-management sketch, not a complete replacement for the fallback skill. Add the initial intent handler and both conversation methods before using the conversational base:

    1. The initial handler calls remember_topic, speaks its prompt, and activates the skill through OVOS when it needs follow-ups.
    2. can_converse checks for live state belonging to the incoming session and a relevant, localized whole-utterance answer.
    3. converse rechecks those conditions before doing work, because a stop or expiry may have happened since selection. Return False for unrelated or expired turns. Renew the TTL only after an accepted turn.

    The OVOS activation window and this store’s TTL are separate. Active status doesn’t prove a topic is still live, and storing a topic doesn’t activate the skill.

    The store has these rules:

    • Reads through get() don’t renew the expiry. Call set() when an accepted turn should renew it.
    • default_ttl=None, or set(..., ttl=None), keeps an entry until removal or capacity eviction. A full store evicts the oldest write.
    • Expiry uses a monotonic clock and happens when the store is accessed. It starts no timer and persists nothing across restarts.
    • remove(session_id) clears one conversation. clear() clears the instance. remove(session_id, expected=state) removes only that same stored object, so cleanup can’t remove a replacement object.
    • Hold store.lock around compound reads and updates to mutable values. Keep blocking I/O and playback waits outside this lock.

    Use the OVOS session ID already resolved from the incoming message. An absent ID should follow an explicit skill policy; the example declines to remember it. Keep the returned OVOS session when sending the next turn, as described in Sessions and Context.

    OVOS still owns stop_session, stop, schedule_event, and cancel_scheduled_event. The example’s session stop succeeds even when its topic has already expired, preventing OVOS from falling through to a global stop that would clear other speakers. Its can_stop only claims an active topic for the requesting session.

    Clearing memory doesn’t stop playback or cancel a timer. Cancel that session’s scheduled work and output separately if your skill owns any. Use the OVOS scheduler for work that must run at a deadline; the store’s lazy expiry runs no callback.

    A random choice can pick the same sound twice. ShuffleBag works like drawing cards from a shuffled pack: use every choice, then shuffle again.

    from random import Random
    from thalovant_skillkit.selection import ShuffleBag
    sounds = ShuffleBag(["duck.ogg", "bear.ogg", "robot.ogg"], rng=Random(7))
    first = sounds.draw()
    second = sounds.draw()
    assert first != second

    Check your result: the first two filenames differ. This only chooses a filename; use OVOS’s play_audio() to play your bundled file. Create the bag in your skill instance so it remembers previous choices between requests. A fixed random seed makes a test repeatable; omit rng during normal use.

    If another speaker plays in between turns, use sounds.draw(avoid=last_heard) with the requesting speaker’s last filename. The bag avoids that sound whenever another choice exists. A one-sound bag always returns its only sound. If the avoided sound is the only one left in a larger bag, drawing starts a fresh cycle. Keep each speaker’s last choice in your session state.

    Update the scaffold’s behavior tests alongside your new vocabulary and replies; retain its separate source-contract test:

    from thalovant_skillkit.testing import message
    from thalovant_skill_garden_watering import GardenWateringSkill
    def test_it_hears_its_keyword():
    assert GardenWateringSkill().can_answer(message("when should I water the garden"))
    def test_it_hears_it_in_french():
    assert GardenWateringSkill().can_answer(message("quand est-ce que tu arroses le jardin", lang="fr-FR"))
    def test_it_ignores_what_is_not_its_business():
    skill = GardenWateringSkill()
    assert not skill.can_answer(message("set a timer for ten minutes"))
    assert not skill.can_answer(message("the sprinklers are noisy today"))
    def test_it_answers_in_french():
    assert GardenWateringSkill().preview_reply("quand est-ce que tu arroses le jardin", "fr-FR") == (
    "Cette compétence de jardin est une démo sans données d’arrosage."
    )

    message() builds a test message with utterance text, language, and optional context. For location-sensitive answers, import MONTREAL from the same module and pass location=MONTREAL. Also test a missing location: location_of returns None and your skill decides how to respond.

    A reminder read aloud should say “tomorrow at nine forty a.m.”; the same reminder in an app should read “May 24, 9:40 AM”. SkillKit’s moments helpers give both, and speak_with_written sends both, so a speaker hears one and a screen shows the other:

    from thalovant_skillkit.moments import (
    date_text, duration_text, speak_with_written, time_text, uses_24_hour_clock,
    )
    def handle_reminder(self, message, due, now):
    lang = self.lang_of(message)
    clock = uses_24_hour_clock(lang)
    spoken = f"Reminder set for {date_text(due, lang, now)} at {time_text(due, lang, clock)}."
    written = (f"Reminder set for {date_text(due, lang, now, written=True)} "
    f"at {time_text(due, lang, clock, written=True)}.")
    speak_with_written(self, spoken, message, lang, written=written)

    date_text keeps “today” and “tomorrow” when the date is that near. duration_text(seconds, lang) says “five minutes” and writes “5:00”. The written form travels beside the spoken one and a device without a screen ignores it. For a reply built from several pieces, WrittenForms.say(spoken, written) records each pair as you compose and WrittenForms.render(reply) produces the written sentence in one pass.

    Learning Lounge, Safety Guide, and Source Scout answer from the Thalovant knowledge service through one shared client rather than three copies of it:

    from thalovant_skillkit.knowledge import POLICY_BLOCKED, POLICY_UNAVAILABLE, knowledge_reply
    from thalovant_skillkit.service import request_headers
    reply = knowledge_reply(
    service_url=self.setting("knowledge_url", ""),
    prompt=question, lang=lang, mode="answer", max_sources=3,
    augmentation="auto", age_policy_setting=self.setting("age_policy"),
    headers=request_headers("my-skill", "1.0.0", "my-skill/1.0.0"), timeout=4.0,
    )
    if reply.policy in (POLICY_BLOCKED, POLICY_UNAVAILABLE):
    # The hub's age policy spoke, or could not be evaluated: say so, do not
    # substitute a packaged answer.
    ...
    elif reply.answer:
    self.speak_to(message, reply.answer)

    reply.provenance says whether the text quotes a source or was written by a language model from the sources it cites, so a skill can tell the listener which it is. The client makes one request and doesn’t retry; validate the settings you pass it.

    If two rooms send requests to the same service, waiting only for its response topic can give both rooms the first answer. Use SkillKit’s correlated waiter with a fresh identifier that the provider echoes:

    from uuid import uuid4
    from ovos_bus_client.message import Message
    from thalovant_skillkit.bus import wait_for_response
    def ask_provider(bus, context):
    request_id = uuid4().hex
    request = Message("example.query", {"id": request_id}, context)
    return wait_for_response(
    bus, request, "example.answer",
    matches=lambda reply: reply.data.get("id") == request_id,
    timeout=3.0,
    )

    Pass the originating message context, including its session. Confirm the provider actually echoes the identifier; this helper can’t change a remote service’s protocol. Validate the returned payload and speak an unavailable reply on timeout or transport failure. The listener is removed on every exit. The finite deadline includes sending, but can’t interrupt a transport that blocks inside emit. Test simultaneous requests, unrelated and late replies, and listener cleanup after timeout.

    Choose the test boundary that matches the behavior you need to prove:

    TestProvesDoes not provide
    Direct calls and testing.FakeBusAnswer logic, locale selection, recorded emissionsDispatch, routing, or a live scheduler
    testing_ovos.skill_harnessSkill initialization, upstream bus dispatch, native scheduled eventsAn utterance pipeline
    managed_minicroft and capture_turnAn installed skill’s actual pipeline routing and bus resultsMicrophone recognition, TTS quality, or real-speaker acoustics

    For the integration examples, install the optional stack in your development environment:

    Terminal window
    python -m pip install --pre "thalovant-skillkit[testing]==0.24.2"

    This extra includes OvoScope and the supported OVOS prerelease dependencies. Install it in the same development environment where you run the integration examples.

    skill_harness constructs the skill with the upstream dispatching FakeBus, a temporary settings file, and the native scheduler service. It cleans up on success or failure:

    from thalovant_skillkit.testing import message
    from thalovant_skillkit.testing_ovos import skill_harness
    from thalovant_skill_garden_watering import GardenWateringSkill
    def test_the_skill_emits_speech():
    with skill_harness(
    GardenWateringSkill,
    skill_id="thalovant-skill-garden-watering.thalovant",
    scheduler=False,
    ) as harness:
    spoken = []
    harness.bus.on("ovos.utterance.speak", spoken.append)
    source = message("when should I water the garden", lang="en-US")
    assert harness.skill.can_answer(source)
    assert harness.skill.handle_fallback(source)
    assert len(spoken) == 1
    assert spoken[0].data["utterance"] in {
    "This garden skill is a demo; it has no watering schedule yet.",
    "I can hear garden questions, but I have no watering data yet.",
    }

    The scheduler is enabled by default, with its timer thread stopped so tests can drive its public tick() method. Use scheduler_autostart=True when testing real delayed delivery. The helper doesn’t supply an intent pipeline; use MiniCroft for utterance routing.

    Isolate XDG paths before pytest collects modules that import your skill. For example, in test/end2end/conftest.py:

    from contextlib import ExitStack
    from thalovant_skillkit.testing_ovos import isolated_xdg
    def pytest_configure(config):
    cleanup = ExitStack()
    config.add_cleanup(cleanup.close)
    cleanup.enter_context(isolated_xdg())

    Each worker gets its own temporary directories, and the original environment is restored afterward. The helper module delays its OVOS imports until needed. OVOScope’s pytest plugin may already have loaded OVOS; MiniCroft’s own configuration isolation remains enabled separately.

    An end-to-end test for the fallback skill can use the upstream fallback pipeline:

    from ovos_bus_client.message import Message
    from ovos_bus_client.session import Session
    from ovoscope import FALLBACK_PIPELINE
    from thalovant_skillkit.testing_ovos import (
    capture_turn,
    deferred_capture_gc,
    managed_minicroft,
    )
    def test_garden_question_routes_and_speaks():
    skill_id = "thalovant-skill-garden-watering.thalovant"
    with deferred_capture_gc(), managed_minicroft(
    [skill_id], default_pipeline=FALLBACK_PIPELINE,
    ) as croft:
    session = Session("garden-test")
    session.lang = "fr-FR"
    source = Message(
    "recognizer_loop:utterance",
    {"utterances": ["quand est-ce que tu arroses le jardin"], "lang": "fr-FR"},
    {"session": session.serialize()},
    )
    turn = capture_turn(croft, source, timeout=30)
    assert any(
    message.data.get("skill_id") == skill_id
    for message in turn.of_type("ovos.intent.matched")
    )
    assert turn.spoken == [
    "Cette compétence de jardin est une démo sans données d’arrosage."
    ]
    assert turn.session.session_id == session.session_id

    capture_turn fails on timeout and always detaches its capture listeners. Its result contains messages, spoken, of_type(topic), and the originating speaker’s latest session; carry that session into a follow-up message. Another speaker’s background update can’t replace it. Add assertions for the expected answer, audio, or service result: a completed capture alone doesn’t prove the skill answered correctly.

    deferred_capture_gc scopes a known OVOScope 1.8.5a1/pyee 12.1.1 finalizer deadlock workaround. It preserves the prior garbage-collection setting and leaves other versions alone. Use it around MiniCroft ownership when testing that combination.

    Cover an unrelated request, a stopped or expired conversation, and two speakers using the skill independently. Run the end-to-end directory explicitly if your normal pytest configuration only selects unit tests:

    Terminal window
    python -m pytest test/end2end --timeout=300 --timeout-method=thread

    A capture can contain messages from several speakers. Use for_session() before checking one speaker’s speech, sound or Stop. It keeps only messages that explicitly carry that session ID; messages without one are left out.

    For example, a quiz test can check the actual WAV bytes queued to a named remote speaker. expected_file is the path to the quiz’s bundled success sound:

    def assert_quiz_sound(turn, session_id, expected_file):
    mine = turn.for_session(session_id)
    assert len(mine.audio) == 1
    sound = mine.audio[0]
    assert sound.extension == "wav"
    assert sound.binary == expected_file.read_bytes()

    Call this after capturing the quiz’s correct-answer turn. Check your result: the test fails if this speaker didn’t receive the expected sound, the wrong file was queued, or the extension was wrong. Check other sessions too when you need to rule out extra copies. Local playback can instead carry a sound.uri, with sound.binary and sound.extension both None. The helper never opens a URI or plays audio. It checks queued requests, not what came out of the loudspeaker.

    For a captured Stop from room A, check turn.for_session("room-a").audio_stops and assert that turn.for_session("room-b").audio_stops is empty. Then send another turn from room B and check its reply. That last step verifies that stopping A preserved B’s game as well as checking the Stop message’s destination.

    spoken, audio and audio_stops prefer current OVOS topic names over their legacy mirrors. Use of_type(topic) when you need to inspect an exact topic. See the audio testing reference for complete payload and filtering rules.

    Terminal window
    thalovant-skillkit check

    The check reports missing locale resources, malformed resource JSON or regexes, invalid class priorities, entry-point problems, and missing packaging declarations.

    Locale checks compare supported locales with the en-US file inventory. Placeholder checks compare sets across each file. French resources, including generated regions based on fr-FR, retain the existing exemption from the English comparison. Regional overrides must still preserve their own source file’s placeholders. These checks don’t verify every translated line. Test rendered replies for every locale you ship, including French. A missing dialog returns its resource name, and missing formatting values can leave template text in the reply instead of raising an error.

    For .intent files, SkillKit also checks whether another skill might answer the same phrases. Read the results: a warning about a skipped phrase check means that part has not been verified. Try more specific examples if your phrases overlap with another skill.

    Build the wheel and source archive, then check their contents against the checkout. Replace the example filenames with those produced for your version:

    Terminal window
    python -m pip install build
    python -m build
    thalovant-skillkit check-artifacts . \
    --wheel dist/thalovant_skill_garden_watering-0.1.0-py3-none-any.whl \
    --sdist dist/thalovant_skill_garden_watering-0.1.0.tar.gz

    The checker reads archives without extracting them or importing the skill. It discovers the first top-level thalovant_skill_* package and checks its files byte for byte. For a src/ layout, supply --package src/your_package; automatic discovery doesn’t descend into src/.

    Missing or changed resources, unsafe or duplicate archive members, and malformed metadata fail the check. When both archives are supplied, their package names and versions must agree. This doesn’t compare their declared version with your checkout’s version; check that release metadata separately.

    Declare additional inventory rules when your skill has runtime assets or offline generators:

    OptionWhat it checks
    --package src/your_packageSelect an import-package directory explicitly; repeat for multiple packages.
    --runtime pathRequire an additional source file or directory in both archives.
    --source scriptsRequire the generator sources in the source archive.
    --wheel-exclude scriptsKeep those generator sources out of the runtime wheel.
    --wheel-count 'your_package/sounds/*.ogg=24'Require an exact matching file count; --sdist-count checks the source archive.

    These options describe files your project actually ships. Audio decoding, loudness, and listening checks remain the audio skill’s responsibility.

    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 entry point lets OVOS find your class. Package data includes the resources it needs after installation. The source check inspects these declarations; only checking the built package establishes that the files arrived.

    From the project directory, install the wheel into a separate environment. Use an absolute wheel path and change out of the checkout before importing, so Python can’t accidentally load your source tree:

    Terminal window
    skill_wheel="$(pwd)/dist/thalovant_skill_garden_watering-0.1.0-py3-none-any.whl"
    skill_smoke_dir="$(mktemp -d)"
    python -m venv "$skill_smoke_dir/venv"
    "$skill_smoke_dir/venv/bin/python" -m pip install --pre "$skill_wheel"
    cd "$skill_smoke_dir"
    "$skill_smoke_dir/venv/bin/python" - <<'PY'
    from importlib.metadata import entry_points
    from importlib.resources import files
    plugin, = entry_points(
    group="opm.skill", name="thalovant-skill-garden-watering.thalovant"
    )
    skill_class = plugin.load()
    assert skill_class.__name__ == "GardenWateringSkill"
    dialog = files("thalovant_skill_garden_watering").joinpath(
    "locale/fr-FR/dialog/garden.watering.dialog"
    )
    assert "démo" in dialog.read_text(encoding="utf-8")
    print("Installed plugin and French resources verified.")
    PY
    cd -

    Loading the entry point proves the installed class can be imported. It doesn’t start a hub; use the MiniCroft test for routing and a real speaker for the final listening check.

    Run the source checks, behavior tests, and built-package checks above after your final code or resource change. Test the languages you support, follow-ups, sound cues, and Stop.

    To hear your skill on a speaker, follow Add Your Own Skill and Add a Skill to a Hub. Ask the same questions you used in your tests, then try an unrelated request. If your skill keeps a conversation going, check that each speaker keeps its own state.

    • 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 don’t handle;
    • the built wheel and source archive pass check-artifacts;
    • the hub answers when you ask out loud.
    What you seeWhat it usually is
    The reply is the dialog file’s nameThe .dialog file is missing, or package_data left locale/ out of the install
    Answers in English when asked in FrenchPass the message language explicitly; check the selected locale and its translated dialog file.
    Another skill answers insteadInspect routing and can_answer first. Within the fallback band, a smaller priority number runs earlier.
    Your skill answers things it should notcan_answer is too broad — check what mentions is matching
    Nothing answers at allThe entry point name does not match the class, or the package is not installed on the hub
    A reply contains {name}A template value is absent; test rendered replies with the data each handler supplies.
    An expired conversation still answersBoth conversation methods must check current session state; OVOS activation alone is insufficient.
    Stopping one speaker clears anotherScope state and scheduled work by session; a false stop_session result falls through to global stop.
    MiniCroft cannot find the skillInstall the project in that test interpreter and check its exact opm.skill entry-point name.
    A capture times outRead the captured message topics and verify pipeline/readiness and terminal signals before increasing the timeout.
    Source checks pass but the hub lacks assetsRebuild, run check-artifacts, and import the wheel from outside the checkout.