Writing Version-Compatible Skills and Plugins¶
In a nutshell
OVOS ships as a dozen independently versioned packages, not one product with one version number. If you maintain a skill or plugin that must run on more than one of those version combinations at once, this page lists the four sanctioned ways to write that compatibility, with a worked example for each drawn from a real break described in Updating From Older OVOS. It also says when to stop shimming and pin a dependency floor instead.
When to bother¶
Most authors should not carry compatibility code. If you publish a skill or plugin
against the current ovos-workshop/ovos-plugin-manager API and pin a sane floor in
pyproject.toml, that is enough. Write compatibility code only when you have a real
reason to support more than one OVOS generation from the same codebase:
- Distro-packaged older cores. raspOVOS images, Debian/Arch packages, or other
fixed snapshots that lag behind the
testingchannel (see Release Channels) and cannot be bumped on demand. - A fleet you do not fully control. HiveMind satellites, community devices, or any install base where you cannot force every node to upgrade together.
- A published plugin with many downstream users. A wake word or TTS plugin on PyPI gets installed against whatever core each user already runs. Breaking one generation to support another has a real cost you do not pay if you just track latest.
If none of these apply, track the current dev API and let your dependency floor do
the work described in Dependency-floor discipline
below.
The four sanctioned techniques¶
a. Import-location fallback¶
Use this when a symbol moved between packages or between modules inside the same
package, and both the old and new location can be imported side by side without
conflict. Try the new path first, fall back to the old one on ImportError.
ovos-workshop 5.0.0 (2025-06-07) moved converse() off OVOSSkill and into its own
base class, ConversationalSkill. Before 5.0.0, a skill overrode converse() directly on
OVOSSkill. From 5.0.0 on, the skill must subclass that base class or the override is
silently never called by the pipeline (ovos-workshop f725f5e, #339):
from ovos_workshop.skills import OVOSSkill
try:
# ConversationalSkill already extends OVOSSkill; listing both bases in
# (OVOSSkill, ConversationalSkill) order raises an MRO TypeError
from ovos_workshop.skills.converse import ConversationalSkill
_BASES = (ConversationalSkill,)
except ImportError:
# pre-5.0.0: converse() lived directly on OVOSSkill, no such base class exists
_BASES = (OVOSSkill,)
class MySkill(*_BASES):
def can_converse(self, message) -> bool:
# only called when ConversationalSkill is present; harmless no-op otherwise
return True
def converse(self, message=None):
...
The same pattern applies to the ovos-utils 0.1.0 gutting (released 2024-09-10; deletion commit 3a77617, 2023-12-29),
which deleted almost every helper that had accumulated in ovos_utils since the
Mycroft era. For example get_mycroft_bus/wait_for_reply moved to
ovos_bus_client.util:
try:
from ovos_bus_client.util import get_mycroft_bus, wait_for_reply
except ImportError:
# pre-0.1.0 (before 2024-09-10): helpers still lived in ovos_utils
from ovos_utils.messagebus import get_mycroft_bus, wait_for_reply
b. Version-gated behavior¶
Use this when the two generations cannot both exist as importable symbols at once, for
example a renamed method on the same class, or a whole class that got deleted rather
than moved. Every OVOS package ships a version.py module with integer constants
(VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD, VERSION_ALPHA) between
START_VERSION_BLOCK/END_VERSION_BLOCK markers. Import the constants from the
package that owns the break and branch on them. This needs no extra dependency and no
string parsing.
ConversationalSkill.can_answer was renamed to can_converse in ovos-workshop
7.0.0 (1fdd532, #348), hours after the can_answer name shipped in 5.0.0 — both landed 2025-06-07.
Define both names and delegate one to the other so callers on either generation find
the method they expect:
from ovos_workshop.version import VERSION_MAJOR as WORKSHOP_MAJOR
class MySkill(ConversationalSkill):
def can_converse(self, message) -> bool:
return True
if WORKSHOP_MAJOR < 7:
# ovos-workshop 5.0.0-6.0.1 called this method can_answer
def can_answer(self, message) -> bool:
return self.can_converse(message)
For a break that landed inside a minor or build release, compare the tuple:
from ovos_workshop import version
if (version.VERSION_MAJOR, version.VERSION_MINOR) >= (7, 1):
... # post-break behavior
The CommonQuerySkill removal is the sharper case: the class itself is gone in
ovos-workshop as of 6382d0a (#400, first shipped in 8.0.4a3), so you cannot import
it unconditionally even inside a try/except used only for typing. Prefer the
modern @common_query decorator (ovos_workshop.decorators.common_query) when it
exists, and only import the deprecated base class on cores old enough to still ship
it:
from ovos_workshop.decorators import common_query
try:
from ovos_workshop.skills.common_query_skill import CommonQuerySkill
_HAS_LEGACY_CQS = True
except ImportError:
# dropped in ovos-workshop 6382d0a (2026-04-08), post-8.0.0 (first in 8.0.4a3)
CommonQuerySkill = OVOSSkill
_HAS_LEGACY_CQS = False
# on modern cores the alias above is OVOSSkill; on cores that still ship it,
# CommonQuerySkill extends OVOSSkill itself, so one base covers both
class MySkill(CommonQuerySkill):
@common_query()
def answer_common_query(self, utterance, lang):
# modern cores dispatch through the decorator; on cores old enough to
# still ship CommonQuerySkill, its own CQS_match_query_phrase/CQS_action
# abstract methods must also be implemented for that path to fire
...
c. Signature both-ways compat for plugin authors¶
Use this when a plugin template method's argument list changed. Give the parameter a default so the same method body satisfies both the old caller, which passes an argument, and the new caller, which calls with none.
ovos-plugin-manager 1.0.0 (ce1c97b, #314) changed the HotWordEngine contract:
found_wake_word(audio_data) became found_wake_word(), with audio now fed
separately through update(chunk). Both mycroft-classic-listener (19d9961, #12,
2026-01-09) and ovos-simple-listener (34e2219, #20, 2026-01-23) landed the
caller-side change when they bumped to opm 2.0.0, so a plugin still expecting one positional
argument gets called with zero and raises TypeError on any updated listener:
from ovos_plugin_manager.templates.hotwords import HotWordEngine
class MyWakeWordPlugin(HotWordEngine):
def update(self, chunk: bytes) -> None:
# opm 1.0.0+ callers feed audio here; older callers never call this,
# so buffer it yourself and fall back to accumulating in found_wake_word
self._buffer = chunk
def found_wake_word(self, audio_data=None) -> bool:
# opm 1.0.0+: called with no arguments, audio already fed via update()
# pre-1.0.0: called with the raw audio chunk directly
chunk = audio_data if audio_data is not None else self._buffer
return self._detect(chunk)
d. Config/data both-ways compat¶
Use this when a config key or data-file schema was renamed and you cannot control whether every consumer on your fleet has upgraded yet. Read (or accept) both the old and the new key, preferring the new one.
Persona files are the shipped example: solver-style handler lists used the key
"solvers". Newer configs use "handlers", and ovos-persona accepts both so a
persona JSON shared across a mixed fleet keeps working during migration (see
Agent Plugin Walkthroughs and Migration):
{
"name": "MixedPersona",
"handlers": ["my-new-chat-engine"],
"solvers": ["ovos-solver-failure-plugin"]
}
If you write code that loads persona files yourself instead of relying on
ovos-persona to do it, read both keys and merge them, new key first:
The OCP-to-ovos-media config split is the same pattern at the deployer-config level.
ovos-media 89a50c0 (#19, 2024-03-29) renamed the config root key OCP to media
and flipped the MPRIS toggle's polarity (disable_mpris, on by default, became
enable_mpris, off by default). Reading both root keys keeps a shared mycroft.conf
working across mixed-version devices:
media_cfg = config.get("media", config.get("OCP", {}))
enable_mpris = media_cfg.get("enable_mpris", not media_cfg.get("disable_mpris", True))
Dependency-floor discipline¶
Every technique above adds branches you must test and maintain. Before reaching for
one, ask whether a pyproject.toml floor or cap is the honest alternative. OVOS
follows a deprecate-then-drop lifecycle almost everywhere (see How OVOS versions
break): active, then deprecated
but functional with a warning, then dropped. If the break you are worried about is
still in its deprecated-but-functional phase, you often do not need a shim at all,
just a floor that excludes the versions before the replacement existed:
[project]
dependencies = [
"ovos-workshop>=5.0.0", # ConversationalSkill base class required, no pre-5.0 shim
"ovos-plugin-manager>=1.0.0", # found_wake_word() no-arg contract only
]
Shim when your users genuinely span both sides of a drop you cannot control (a distro
snapshot, a fleet you do not own). Pin a floor when you control your own install
target and would rather drop old-version support than carry branches forever. A floor
is also the only honest choice once a technique above stops being possible, for
example after CommonQuerySkill is fully removed and there is no import to fall back
to.
Testing across versions¶
Test every branch you ship, not just the current one. A compat shim with no test for its old-version branch is dead code with a false sense of safety.
The manual's constraints files
pattern gives you a ready-made two-version CI matrix. Install against the testing
channel for the current behavior and against a pinned older constraints snapshot (or
an explicit old-version pin) for the legacy branch:
# .github/workflows/test-compat.yml
name: Compat matrix
on: [push, pull_request]
jobs:
test:
strategy:
matrix:
include:
- name: current
constraints: https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-testing.txt
- name: legacy
constraints: https://raw.githubusercontent.com/OpenVoiceOS/ovos-releases/refs/heads/main/constraints-stable.txt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install (${{ matrix.name }})
run: uv pip install ".[test]" -c "${{ matrix.constraints }}"
- name: Run tests
run: pytest test/
Write one test per branch of every compat shim: one that asserts the new-path behavior when the modern symbol is importable, and one that asserts the fallback fires correctly when it is patched out or the modern symbol is absent. A shim that only ever runs its new-path branch in CI has never actually tested the old-version support it claims to offer.
When NOT to do this¶
Some breaks are not shimmable, and trying anyway produces code that looks safe and is not.
Wire behavior scheduled for removal. The ovos-bus-client legacy-topic namespace
bridge (modernize/emit_legacy, both default ON from e25ab12, 2026-06-25) is
scheduled for deletion by the open kill-switch
ovos-bus-client#272. After
it merges, MessageBusClient speaks OVOS-MSG-1 spec topics only and passing
emit_legacy, modernize, or intent_reemit_blanket to the constructor raises
RuntimeError.
The bridge re-dispatches incoming messages on the receive side, and since bus-client
2.8.3a1 a canonical emit of any mapped topic also puts a real, marked legacy twin frame on the
wire by default (opt out with OVOS_BUS_WIRE_LEGACY_TWINS or websocket.wire_legacy_twins).
One canonical emit() is therefore two websocket messages on current defaults. See Bus
Namespace Migration and size bus traffic accordingly.
There will be no client-side shim for this: migrate remote clients and
satellites to ovos.* spec topics while the bridge still covers both spellings. Do
not write new code that shims legacy topic names. See The bus-client
legacy-topic dual-emit and its removal
for the full migration path.
Pre-fork mycroft-core. The MycroftSkill compat metaclass that let classic
Mycroft skills load under OVOS was removed in ovos-workshop 2d684a1 (#235,
2024-10-15). There is no import-fallback or version gate that restores it, because the
whole loading and initialization model changed, and it is not only a symbol location. If you are
still carrying a skill written against mycroft-core, that is a port, not a compat
shim. Follow Migrating From Mycroft instead.
Read next: Skill Design Best Practices Related: Migrating from Mycroft · Skill Metadata File · Runtime Requirements in OVOS · Maturity Scale