Session Aware Skills¶
Maturity — Mature ⬤⬤⬤⬤⬤
Long-lived and actively maintained. Depend on it freely. Rated by repository health, not version.
In a nutshell
One OVOS device can talk to several people at once: your phone, a kitchen speaker, and other connected devices may all be asking it things at the same time. A session is simply the information that says who is asking and in what language. If your skill remembers anything between requests, like a running game or a chat history, it needs to keep each person's information separate so two users don't get each other's answers, much like separate tables at a restaurant. Key that state by session_id instead of stashing it in a single instance variable. This page shows how to make a skill session aware. New terms are explained in the Glossary.
Formal specification
SESSION-1 is the field registry. Other specs claim fields into it (e.g. intent_context → CONTEXT-1, the transformer-chain lists → OVOS-TRANSFORM-1). See also the spec index.
- OVOS-SESSION-1 — Session Carrier: the session's wire shape and field registry.
- OVOS-SESSION-2 — Session Lifecycle & State Ownership: who owns and may mutate session state, and the reserved
"default"device-local session. - OVOS-CONTEXT-1 — Intent Context: the decaying per-session
intent_contextthat gates which intents may match across turns.
flowchart LR
Sat["Voice satellite / HiveMind node"] -->|Message + Session| Bus["Messagebus"]
Bus --> SM["SessionManager"]
SM -->|"default" session, device-local| Core["ovos-core orchestrator"]
SM -->|unique session_id, external client| Core
Core -->|SessionManager.get message| Skill["Skill"]
Diagram: The flow starts at the voice satellite or HiveMind node, passes through the messagebus and SessionManager, and ends at the skill, branching on whether the session is the device-local "default" session or a unique external session_id.
If you want your skills to handle simultaneous users, make them Session aware.
Each remote client, usually a voice satellite, sends a Session with the Message.
Your skill should keep track of any session-specific state separately, for example a chat history.
WARNING: Stateful Skills need to be Session Aware to play well with HiveMind
SessionManager¶
You can access the Session in a Message object via the SessionManager class
from ovos_bus_client.session import SessionManager, Session
class MySkill(OVOSSkill):
def on_something(self, message):
sess = SessionManager.get(message)
print(sess.session_id)
If the message originated in the device itself, the session_id is always equal to the reserved value "default". If it comes from an external client, it will be a unique uuid. The "default" session is special: it is the device-local session whose state the orchestrator holds and persists in-process, rather than receiving it from a client on every message (OVOS-SESSION-2 §5).
A session_id that cannot serve as an identity, absent, null, empty, or any non-string value, resolves to "default" rather than minting a fresh session. This applies uniformly wherever a session carrier is read.
SessionManager.get(message) is a pure read. It never writes the store. The default session is folded into the store exactly once per utterance, at intake, by SessionManager.fold_inbound. This happens before any handler runs. SessionManager.get only returns the live object; it never re-triggers a write. Call it anywhere in a component.
Always pass the message when you read a session
That intake fold happens in the orchestrator's process (ovos-core). A process
whose bus client is not the orchestrator, an out-of-process skill container, the GUI,
or a satellite, does not mirror observed default-session traffic into its own local
store. Only a named carrier is routed through update() there.
Inside a handler this costs you nothing: SessionManager.get() with no argument digs
out the message being handled, so fields a satellite injected are read from that
message's carrier. Outside any handler there is no message to dig, and the read falls
back to the deployment default rather than to whatever the orchestrator currently
holds. Pass the message explicitly, SessionManager.get(message), wherever you have
one. dig_for_message explains how that dig finds the
message, and why a handler that runs off the dispatch thread can find the
wrong one or none at all.
For a named session, repeated calls to SessionManager.get(message) for the same message return the same bound Session object. A message derived from it, message.forward(...), message.reply(...), or a bus response, carries that bound object's current state, not a stale copy of the carrier the original message arrived with. Mutate the Session object get returned. Do not edit message.context["session"] directly after calling get: the bound object wins when a derived message is stamped.
One microphone, one conversation
Because everything the on-device microphone hears lands in the single "default"
session, two people in the same room cannot hold independent multi-turn conversations
with the same device: their turns interleave in one shared context. Separate
conversations require separate clients (a HiveMind satellite, a phone app), each of
which gets its own session_id.
A bare session_id does not carry state — replay the whole session
An external client (a HiveMind satellite, any non-"default" session) is not tracked
in-process the way the device-local session is. ovos-core only refreshes what it
already holds for a given session_id. A session_id this process never folded a full
session for is carried through untouched, with none of the previous turn's state. To keep
multi-turn continuity for intent_context, lang, presentation preferences, and the rest,
a client must send the complete serialized session back on each message, not just the
bare session_id string. From Python that is Session.serialize(). From any other
language it is the same JSON object echoed back from context.session of the frames
the server sends you (its exact field list is normative in the OVOS-SESSION-1 spec, not
this manual, see the JSON round trip). Losing that round trip is
indistinguishable from starting a brand new session every turn. A session_id this
process has never folded a full session for gets a fresh session by design, not an
error and not a reconstruction from history.
There is no topic that pushes an updated session from the orchestrator down to a
client to keep it current. ovos.session.sync is the closest thing, and it runs the
other way: a client emits it carrying its own snapshot, and the receiver merges that
snapshot's intent_context onto whatever it already holds for that session_id
(OVOS-CONTEXT-1 §5.3). This is a pull-shaped merge triggered by the sender, not a broadcast.
A bare ovos.session.sync with no session carrier is handled differently again: it is
read as a legacy request for the current default session and answered with a
ovos.session.update_default echo, which only ever concerns the device-local
"default" session, never an external client's. Multiple clients each carrying their
own full session converge on consistent state by every one of them adopting this
same discipline, not by any message that reconciles them from the server side.
Connecting to the bus does not announce anything either: a client derives its own
default session from local configuration and converges by adopting what it
observes on the bus, not from a push at connect time (ovos-bus-client >=
2.11.6a1, OVOS-SESSION-2 §2.7). A skill container on an older ovos-bus-client
joining a pre-spec bus (ovos-core 1.3.x) instead overwrote the core's default
session on connect, breaking local intent matching — upgrade both sides together.
A present-but-malformed session never crashes the bus client
Session.from_message treats an absent session key (or an explicit
null) as "use the default session," which is completely normal. A session
key that is present but is not a JSON object (a bare string, a number)
is a different case: a producer bug. Rather than raising and tearing
down the whole bus connection, the client discards just that one
malformed message and logs a warning, so one badly-behaved emitter can't
force every other client into a reconnect loop.
Language signals¶
lang is one of six BCP-47 language fields a session may carry (SESSION-1 §3.2). Each
names a different kind of signal. Each is optional and written independently, by
different components or different stages of the pipeline. Their meanings are fixed. How a
consumer folds them into one language for a given operation is not fixed, and is left to the stage
doing the work.
| Field | Meaning | Typically written by |
|---|---|---|
lang |
The participant's preferred language: the stable base signal for the session, and the fallback when nothing per-utterance is available | The client or bridge that opened the session, or the deployment default otherwise |
secondary_langs |
Additional languages the participant also speaks, most-preferred first. Never contains lang itself |
The client or bridge that opened the session |
output_lang |
The language the participant wants replies rendered in, independently of what they speak | The client, or a user preference. Consumed by dialog/prompt rendering |
stt_lang |
The language the speech-to-text stage was configured to assume for the audio. Diverges from the transcript's language when a speech-translation model is used | The audio input service, before or at STT invocation |
request_lang |
The language the emitter reported for this utterance: a hint, never authoritative (e.g. the language bound to the wake word that fired) | The emitter: listener, UI selector, or a routing layer |
detected_lang |
The language a detection component classified the utterance as. May disagree with both stt_lang and lang. Disagreement is normal |
A language-detection plugin or transformer |
Rough guidance on which to read: render responses in output_lang when it is set. Constrain a
detector's candidate set with lang plus secondary_langs. For intent matching, ovos-core
does not read a single field in isolation. See Language Selection for the
authoritative resolution order (stt_lang → request_lang → detected_lang → configured
default). Never assume a field is present, and never assume one equals another.
data.lang is per-payload, not session state
Many bus topics carry a data.lang describing the language of the content in that
message, the utterance just transcribed, the dialog just rendered. It is owned by the
spec defining the topic, is not a session field, and is not propagated with the session
(SESSION-1 §3.2.8). A consumer that needs a payload's content language reads data.lang
directly and must not assume it equals session.lang or any other session signal.
TTS voice selection keys on data.lang for exactly this reason.
See Language Selection and Disambiguation for how ovos-core resolves
these signals into the language it matches an utterance in.
Intent context¶
A session also carries intent_context: a per-key decaying context store that gates which
intents may match across turns (e.g. "book a flight" setting context so a follow-up "to
Paris" is understood without repeating "flight"). It is a session field claimed into the
SESSION-1 registry by OVOS-CONTEXT-1, holding {value, expires_at} per key. Each entry
decays via its expires_at (computed from the context.timeout config value, minutes,
default 2) or a turns_remaining budget, and a re-set refreshes the expiry. This includes
entries written through set_context (see Conversational Context). See
Intent Service for how context is set, read, and consumed during
pipeline matching.
Deployment-owned fields¶
A pipeline plugin that returns updated_session may mutate the
session, but not every field is the plugin's to change. pipeline, the six transformer-chain
lists (OVOS-TRANSFORM-1 §5), the three blacklist denylists (blacklisted_skills,
blacklisted_intents, blacklisted_pipelines), and site_id belong to the deployment, not to a
match round: the orchestrator re-imposes the value it held before the plugin ran onto every one
of them, discarding whatever the plugin's returned session carried (SESSION-1 §3 / OVOS-PIPELINE-1
§5.5). A plugin that means to change a deployment-owned field has to change it at the deployment,
not by writing to the session. Fields the round itself owns, like active_handlers and
converse_handlers, pass through untouched.
Presentation preferences¶
Beyond session_id and the language signals, a session carries presentation
preferences that follow the session's originator rather than the device.
This is useful when a remote participant (a HiveMind satellite, a different-locale
caller) wants times, dates, units, and place-relative answers rendered for
their locale: location, system_unit ("metric" / "imperial"), time_format
("full" for 24-hour, "half" for 12-hour), and date_format (e.g. "DMY" / "MDY").
All four are optional. An absence falls back to the deployment default, and location
is what backs the location / location_pretty / location_timezone magic properties
below.
location (OVOS-SESSION-1 §3.5) recognizes exactly three optional keys: lat and lon
(decimal degrees, WGS84), and tz (an IANA zone name). Everything else, city, country,
offsets, daylight-saving state, is derived out of band and has no wire shape. The one
normative consumer rule: when location.tz is present, a consumer must use it to
resolve wall-clock time for that session, and fall back to the deployment timezone
otherwise.
Magic Properties¶
Skills have some "magic properties." These reflect the current Session's value when it has
one, falling back to Configuration otherwise.
# magic properties -> depend on message.context / Session
@property
def lang(self) -> str:
"""
Get the current language as a BCP-47 language code. This will consider
current session data if available, else Configuration.
"""
@property
def location(self) -> dict:
"""
Get the JSON data struction holding location information.
This info can come from Session
"""
@property
def location_pretty(self) -> Optional[str]:
"""
Get a speakable city from the location config if available
This info can come from Session
"""
@property
def location_timezone(self) -> Optional[str]:
"""
Get the timezone code, such as 'America/Los_Angeles'
This info can come from Session
"""
@property
def dialog_renderer(self) -> Optional[MustacheDialogRenderer]:
"""
Get a dialog renderer for this skill. Language will be determined by
message context to match the language associated with the current
session or else from Configuration.
"""
@property
def resources(self) -> SkillResources:
"""
Get a SkillResources object for the current language. Objects are
initialized for the current Session language as needed.
"""
The default session merges field by field; a named session does not
The inbound fold onto the "default" session, SessionManager.fold_inbound, is a
field-by-field merge. A field the message carries replaces the stored value. A field
it omits leaves the stored value standing. This is why a device that only declares
intent_context on one turn keeps its other fields, such as blacklisted_skills and
presentation preferences, across turns instead of losing them the moment any message
omits them.
A named session_id gets no such treatment. The orchestrator holds no state for it
between utterances (OVOS-SESSION-2 §2.2). Its session is built fresh from whatever
that one message's carrier contains. This is why the earlier warning on this page
insists a client replay the complete session on every message for a named
session: there is nothing stored to fill in what it omits.
Per User Interactions¶
Let's consider a skill that keeps track of a chat history, how would such a skill keep track of Sessions?
from ovos_bus_client.session import SessionManager, Session
from ovos_workshop.decorators import intent_handler
from ovos_workshop.skills import OVOSSkill
class UtteranceRepeaterSkill(OVOSSkill):
def initialize(self):
self.chat_sessions = {}
self.add_event('recognizer_loop:utterance', self.on_utterance)
# keep chat history per session
def on_utterance(self, message):
utt = message.data['utterances'][0]
sess = SessionManager.get(message)
if sess.session_id not in self.chat_sessions:
self.chat_sessions[sess.session_id] = {"current_stt": ""}
self.chat_sessions[sess.session_id]["prev_stt"] = self.chat_sessions[sess.session_id]["current_stt"]
self.chat_sessions[sess.session_id]["current_stt"] = utt
# retrieve previous STT per session
@intent_handler('repeat.stt.intent')
def handle_repeat_stt(self, message):
sess = SessionManager.get(message)
if sess.session_id not in self.chat_sessions:
utt = self.resources.render_dialog('nothing')
else:
utt = self.chat_sessions[sess.session_id]["prev_stt"]
self.speak_dialog('repeat.stt', {"stt": utt})
# session specific stop event
# if this method returns True then self.stop will NOT be called
def stop_session(self, session: Session):
if session.session_id in self.chat_sessions:
self.chat_sessions.pop(session.session_id)
return True
return False
A full example can be found in the parrot skill
Read next: Statements Related: Converse · Context · Configuration Management · Skill Settings