# Keep Your Skill Responsive

> Use SkillKit for cached resources, varied sounds, bounded service calls, background refresh, and optional persistence without copying the plumbing.

Source: https://docs.thalovant.com/developers/skillkit-runtime/

---
A skill should spend its time answering the person, rather than finding the same
files again or waiting too long for a service. SkillKit provides small
helpers for this repeated work. Choose only the ones your skill needs.

Start with [Build an Educational Quiz](/developers/skillkit-tutorial/) if you've
not written a skill yet. These examples extend an existing project. Install with
`python -m pip install --pre "thalovant-skillkit==0.24.2"`, and declare `thalovant-skillkit>=0.24.2`
in your project's dependencies.

## Choose A Helper

| What you are adding | Use | Your skill still decides |
| --- | --- | --- |
| Many packaged sounds | `bundled_files` and `ShuffleBagPool` | Which sound fits the game and which speaker hears it |
| Several translated replies | `self.speak_varied_dialog` | The actual words and when to invite an answer |
| An online service | `JsonServiceClient` and `RequestBudget` | The request, validation and unavailable reply |
| A catalog that refreshes occasionally | `PeriodicWorker` | Freshness, cancellation and safe cache replacement |
| Optional Redis/PostgreSQL state | `JsonStateStore` | Record validation, ownership and local recovery |
| One game or draft per player | `SessionStateStore` | What a turn changes, and when a game is over |
| Regional aliases or blocklists | `SkillResources.combined_lines` | Which languages the feature should accept |

These helpers don't add a service call to an offline skill. Importing them does
not start a worker or connect to storage. OVOS still handles speech, audio,
intent routing, conversations and scheduled alarms.

## Find Sounds Once, Then Pick Quickly

Imagine a box of animal sound cards. You first list the cards, then draw from the
list each time someone asks. You don't unpack the whole box for every turn.

```python
from pathlib import Path
from thalovant_skillkit.assets import bundled_files
from thalovant_skillkit.selection import ShuffleBagPool

sound_dir = Path(__file__).parent / "sounds"
sounds = bundled_files(sound_dir, "*.ogg")
choices = ShuffleBagPool()

# Run inside an intent handler after adding at least two sounds to your package:
# chosen = choices.draw(sounds)[0]
# self.play_audio(str(chosen))
```

Create `choices` once per skill instance. `bundled_files` remembers a sorted tuple
of filenames; it doesn't load the audio into memory. There are at most 128 cached
directory/pattern combinations. The first call scans the directory; repeated calls
reuse the list. Remember to include your sounds in the built wheel.

**Check your result:** call `bundled_files(sound_dir, "*.ogg")` twice, then inspect
`bundled_files.cache_info()`. The second call increases `hits`. Draw twice from a
pool containing two or more distinct files; the choices differ. A one-file pool
can only return that file. Selection alone doesn't play audio.

For replies, add multiple lines to the usual translated `.dialog` file and call:

```python
# Inside an intent handler; translate quiz.correct.dialog in each supported language.
self.speak_varied_dialog("quiz.correct", {"name": "Sam"})
```

Kit remembers the choices while OVOS handles rendering, resource overrides and
speech. If you reload an override with different lines, its selection bag is
replaced. Ordinary `speak_dialog` keeps its normal behavior. For multiple players,
keep each player's last sound in session state and pass it as `avoid=last_sound`.
Kit avoids that choice when another is available.

## Give Online Work A Time Budget

A timeout is how long one operation may wait. A reply budget is the shared amount
of time available for all the operations in that reply. Without a budget, three
one-second calls could keep someone waiting for roughly three seconds.

```python
from thalovant_skillkit.network import JsonServiceClient, RequestBudget

# Create these once in your skill instance.
budget = RequestBudget()
service = JsonServiceClient("garden-watering", "1.0.0", budget=budget)

# Use your configured service URL. An empty URL means no service is configured.
service_url = ""
with budget.limit(2.0):
    result = service.post(service_url, {"question": "does the garden need water?"},
                          timeout=1.0)

if result is None:
    answer = "I cannot check the garden right now."
else:
    # Validate the service's fields before using them in your real skill.
    answer = str(result.get("answer", "I could not find an answer."))
```

**Check your result:** with the empty URL, `result` is `None` immediately and the
fallback text is selected. With a configured service, a valid JSON object becomes
a dictionary. A failed request, invalid response or exhausted budget returns
`None`; your skill decides how to answer.

The client sends identifying and request-ID headers, caps responses at 2 MiB by
default and attempts each request once. It doesn't retry POST requests that may
already have performed an action. After a failure, it skips that URL for 30 seconds
by default so later turns don't repeatedly wait on the same broken service.
Other URLs remain available. The cooldown has at most 128 entries. Successful
responses aren't cached or shared between speakers.

Deadlines are separate for concurrent threads, and a nested budget can't extend
its parent. They are checked between reads; they can't forcibly interrupt a
blocked socket or DNS lookup. Always use a finite transport timeout too. A stalled
read can finish up to one socket timeout after the budget expires.

For a skill that loads offline but needs internet for some features, use these
class attributes instead of copying a `RuntimeRequirements` property:

```python
REQUIRES_INTERNET = True
INTERNET_BEFORE_LOAD = False
NO_INTERNET_FALLBACK = True
```

Your skill must actually implement its offline reply. These flags describe that
behavior to OVOS; they don't create the reply.

## Refresh Away From The Reply

News refreshes audio addresses in the background. A listener can then use a recent
address without waiting for every feed to be checked again. The same pattern suits
a small public catalog that can tolerate a little age.

```python
from thalovant_skillkit.workers import PeriodicWorker

# In your skill's initialize(), after any required super().initialize():
self.refresh = PeriodicWorker(self.refresh_catalog, interval=600,
                              name="garden-catalog-refresh")
self.refresh.start()

# In shutdown(), before super().shutdown():
self.refresh.stop(timeout=1.0)
```

Implement `refresh_catalog(self, stop_event)`. Bound each network operation, check
`stop_event.is_set()` between items, and replace a completed snapshot only after
validating it. Keep locks short; don't hold a cache lock while downloading.

**Check your result:** a first refresh runs immediately. The next begins 600
seconds after that pass finishes. Calling `start()` again doesn't create a second
worker. Exceptions end that pass and are logged; the next pass waits the normal
interval. `stop()` interrupts the idle wait and reports whether the thread exited.
It can't kill a callback that ignores cancellation or blocks indefinitely.

Use OVOS's scheduler for a user's timer or alarm. This worker is for optional
refresh work, not exact deadlines. The skill decides how long data remains usable
and what to say if it's too old. Don't put private details in exception messages.

## Save State Without Copying Backend Code

Use `JsonStateStore` when your skill needs optional shared storage. With no
backend configured, a new store returns `None` from `load()` so your skill can
use its local fallback.

```python
from thalovant_skillkit.storage import JsonStateStore

store = JsonStateStore("household", {}, env_prefix="GARDEN", default_key="watering")
records = store.load()
```

Install `redis` for Redis/Sentinel and `psycopg` for PostgreSQL if you use them.
Settings take precedence over the skill's prefixed environment, then generic
backend environment variables. Check the result of each save: configuring shared
storage alone doesn't guarantee that a record was saved. Your skill retains local recovery, validates records and
checks who owns them. Don't use this helper to bypass a service's own storage API.

See the [storage contract](https://github.com/thalovant/thalovant-skillkit/blob/main/docs/reference.md#optional-shared-persistence)
for configuration keys, settings, return values, and connection failure behavior.

## Keep One Game Per Player

Two people can play the quiz at once, and one shared score would mix their games. `SessionStateStore` keeps one entry per session ID: the skill reads the ID from the message and stores that player's game under it.

```python
from thalovant_skillkit.sessions import SessionStateStore

# Once per skill instance: at most 128 players, forgotten after 90 quiet seconds.
self.games = SessionStateStore(max_entries=128, default_ttl=90)

# In a turn:
with self.games.lock:
    game = self.games.get(session_id)
    ...
    self.games[session_id] = updated_game
```

Writing an entry refreshes its countdown; reading doesn't. When the store is full, the oldest entry goes. Expiry is checked when the store is used, so nothing runs in the background. The store belongs to one skill instance; a second instance doesn't see its games. Keep the lock for the decision, not for a network call: the quiz reserves the player's turn, releases the lock while it fetches questions, then checks that Stop didn't arrive in the meantime.

**Check your result:** start two games with different session IDs and answer one; the other's score is unchanged. Advance a clock past the TTL and the game is gone. Read the tutorial's [`game.py`](/developers/skillkit-tutorial/#lesson-3-build-the-game) for the complete version.

## Know When To Refresh A Cache

| Cached data | When it changes | How to refresh |
| --- | --- | --- |
| Bundled sound filenames | You install or edit package assets | A process restart, or `bundled_files.cache_clear()` between turns |
| Locale text and combined aliases | You install or edit translations | A new resource instance, or `self.locale_resources.clear_cache()` between turns; a game already in progress keeps the questions it started with |
| Dialog/sound selection history | Choices or their explicit key change | Kit replaces a changed pool; eviction forgets history only |
| Service failure cooldown | Its interval expires | Kit permits a new request automatically |
| Service replies or personal state | Depends on the application | No generic reply cache is added; define a scoped freshness policy if needed |

`combined_lines(lang, folder, filename)` collects accepted aliases or patterns in
language preference order, including the configured default. Its combined-result
cache holds at most 512 entries. Spoken dialogs should use the usual dialog lookup
so they answer in one language. Cache clearing doesn't coordinate simultaneous
file edits: reload between turns. Use ordinary directory reads for live user
uploads, not the bundled-file cache.

`matches_literal_intent` also caches the normalised phrases for each resource
and compatible language set. Reuse `self.locale_resources` between turns and
pass a fixed resource name, such as `pocket_quiz.start`. You do not need a second
cache in your skill. `clear_cache()` clears these matches and speech-twin lookups
too after a resource edit.

## Measure The Part You Changed

Measure the same action before and after your change with the same Python and
OVOS versions. Warm the cache first, repeat the action, and compare several runs
without other tests competing for CPU. Also measure a fresh process: a fast warm
request can still have a slow startup.

Separate local selection time from service, speech synthesis and speaker latency.
Caching a sound list makes selection faster; it doesn't make a slow audio device
start sooner. Test both cache hits and invalidation, and use a network-disabled
benchmark for local work. The [SkillKit reference](https://github.com/thalovant/thalovant-skillkit/blob/main/docs/reference.md)
and [audio checks](/developers/writing-a-skill/#check-sound-and-stop-routing) cover
helper contracts and the additional checks for real spoken turns.
