Skip to content

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.

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_langrequest_langdetected_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