Keep Your Skill Responsive
Use SkillKit for cached resources, varied sounds, bounded service calls, background refresh, and optional persistence without copying the plumbing.
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 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
Section titled “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
Section titled “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.
from pathlib import Pathfrom thalovant_skillkit.assets import bundled_filesfrom 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:
# 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
Section titled “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.
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:
REQUIRES_INTERNET = TrueINTERNET_BEFORE_LOAD = FalseNO_INTERNET_FALLBACK = TrueYour 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
Section titled “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.
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
Section titled “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.
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 for configuration keys, settings, return values, and connection failure behavior.
Keep One Game Per Player
Section titled “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.
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_gameWriting 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 for the complete version.
Know When To Refresh A Cache
Section titled “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
Section titled “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 and audio checks cover helper contracts and the additional checks for real spoken turns.