# Writing a Skill

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

Source: https://docs.thalovant.com/developers/writing-a-skill/

---
Aside,
  Steps,
} from "@astrojs/starlight/components";

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](/developers/skillkit-tutorial/).** You'll build one working skill in three short lessons, each with a result to check. [What SkillKit Gives You](/developers/skillkit-capabilities/) 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](https://github.com/thalovant/thalovant-skillkit/blob/main/docs/reference.md) for full helper signatures and return values.

## Python And A Terminal

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:

```bash
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.

## Start From A Working Skill

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

```bash
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.

## Make It Yours

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`:

   ```python
   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.

## Choose The Right Base

| Your interaction | Base 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 handle | `ThalovantFallbackSkill` 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 turn | `ThalovantConversationalSkill` with an initial handler, `can_converse`, and `converse`. Activation and routing stay with OVOS. |
| Media search and playback through OCP | `ThalovantCommonPlaySkill` 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.

## Let SkillKit Handle Repeated Work

Use [Keep Your Skill Responsive](/developers/skillkit-runtime/) 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.

## Choose A Fallback Priority

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

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

  A broad claim can prevent a more specific fallback from answering. Test
  unrelated requests before changing priority; a priority change can't fix an
  overbroad `can_answer` implementation.

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.

## What You Can Call

|                                    |                                                   |
| ---------------------------------- | ------------------------------------------------- |
| `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_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. 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.

  `self.voc_match` is OVOS's and matches whole words, so a file listing `log`
  doesn't match "logs". `self.mentions` also accepts a short ending, so "logs"
  counts while "technology" still doesn't. Use `mentions` for keywords people
  inflect, `voc_match` when you mean the exact word. Neither test requires the
  whole utterance to equal the term: a follow-up such as "again" needs a
  separate whole-utterance check to avoid claiming "set that timer again".

## Write Intent Phrases OVOS Can Read

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:

```text
[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.
`` 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.

### Read Alternative Transcripts

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

```python
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.

## Add Pauses And Pronunciation

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`:

```text
Ready, {name}? Let's play.
```

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

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

Speak both forms through SkillKit:

```python
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:

```python
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.

## Add Another Language

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.

### Support Regional Variations

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`:

```python
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.

### Generate Complete Regional Resources

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:

```json
{
  "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:

```bash
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 translation | Common regional targets |
| --- | --- |
| `en-US` | `en-CA`, `en-GB`, `en-AU`, `en-NZ` |
| `fr-FR` | `fr-CA`, `fr-BE`, `fr-CH` |
| `es-ES` | `es-MX`, `es-AR`, `es-CO`, `es-US` |
| `de-DE` | `de-AT`, `de-CH` |
| `pt-PT` | `pt-AO`, `pt-MZ` |
| `nl-NL`, `sv-SE` | `nl-BE`, `sv-FI`, respectively |
| `zh-CN` | `zh-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](https://github.com/BYVoid/OpenCC#configurations-配置文件)
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`](https://github.com/thalovant/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:

```python
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.

```bash
python -m pip install --upgrade "thalovant-languages>=0.3.1"
thalovant-languages check
```

The check should report no data errors. Releases reach
[PyPI](https://pypi.org/project/thalovant-languages/) first;
[piwheels](https://www.piwheels.org/project/thalovant-languages/) may follow later.

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

```python
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

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:

```python
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](/developers/sessions-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.

## Keep Questions And Sounds Varied

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

```python
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.

## Test It

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

```python
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.

  Keep the "it ignores" test. A skill that answers too much is harder to notice
  than one that answers too little, because nothing fails — another skill just
  stops being heard.

## Say A Date Or Time Both Ways

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:

```python
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.

## Ask The Knowledge Service

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

```python
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.

## Keep Shared Bus Replies With Their Caller

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:

```python
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.

## Exercise The Real OVOS Bus

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

| Test | Proves | Does not provide |
| --- | --- | --- |
| Direct calls and `testing.FakeBus` | Answer logic, locale selection, recorded emissions | Dispatch, routing, or a live scheduler |
| `testing_ovos.skill_harness` | Skill initialization, upstream bus dispatch, native scheduled events | An utterance pipeline |
| `managed_minicroft` and `capture_turn` | An installed skill's actual pipeline routing and bus results | Microphone recognition, TTS quality, or real-speaker acoustics |

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

```bash
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:

```python
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.

## Test An Utterance Through MiniCroft

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

```python
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:

```python
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:

```bash
python -m pytest test/end2end --timeout=300 --timeout-method=thread
```

## Check Sound And Stop Routing

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:

```python
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](https://github.com/thalovant/thalovant-skillkit/blob/main/docs/testing-audio.md)
for complete payload and filtering rules.

## Check It

```bash
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.

## Check What You Built

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

```bash
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:

| Option | What it checks |
| --- | --- |
| `--package src/your_package` | Select an import-package directory explicitly; repeat for multiple packages. |
| `--runtime path` | Require an additional source file or directory in both archives. |
| `--source scripts` | Require the generator sources in the source archive. |
| `--wheel-exclude scripts` | Keep 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.

## Verify The Installed Plugin

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

```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:

```bash
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.

## Prepare A Release

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](/manage/add-your-own-skill/) and [Add a Skill to a Hub](/manage/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.

## Done When

- `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 The Symptom Usually Means

| 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 | Pass the message language explicitly; check the selected locale and its translated dialog file. |
| Another skill answers instead           | Inspect routing and `can_answer` first. Within the fallback band, a smaller priority number runs earlier. |
| 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 |
| A reply contains `{name}`               | A template value is absent; test rendered replies with the data each handler supplies. |
| An expired conversation still answers   | Both conversation methods must check current session state; OVOS activation alone is insufficient. |
| Stopping one speaker clears another     | Scope state and scheduled work by session; a false `stop_session` result falls through to global `stop`. |
| MiniCroft cannot find the skill          | Install the project in that test interpreter and check its exact `opm.skill` entry-point name. |
| A capture times out                      | Read the captured message topics and verify pipeline/readiness and terminal signals before increasing the timeout. |
| Source checks pass but the hub lacks assets | Rebuild, run `check-artifacts`, and import the wheel from outside the checkout. |
