The Message class is the on-the-wire envelope that OVOS components
exchange — utterances coming in, intent matches going out, skills
announcing handler outcomes, hosts dispatching to skills. The shape is
defined by OVOS-MSG-1 and this module is the reference
implementation.
Every Message is a JSON object with exactly three top-level fields:
| Field | Type | Meaning |
|---|---|---|
type |
string | The topic — ovos.intent.matched, speak, <skill_id>:<intent_name>, … |
data |
object | Topic-specific payload; shape fixed by whichever spec defines type |
context |
object | Routing keys, the session carrier, and any layer-2 metadata |
from ovos_spec_tools import Message
m = Message("speak", {"utterance": "hello"}, {"source": "skill.id"})The constructor rejects malformed input: an empty type, a non-string
type, or a non-dict data / context raise
MalformedMessage. Pass the dicts by reference — they are stored
as-is; if you need a detached copy use the derivations below.
Two context keys mark the OVOS/handler-code boundary:
source— opaque identifier of the producer;destination— opaque identifier (or list of identifiers) of the intended consumer(s). Absent or empty means broadcast.
The envelope treats both as opaque strings — no parsing, no validation
beyond "is it there". How identifiers are minted is a deployment
concern. Hivemind, for example, uses source / destination to thread
remote peers through the bus without OVOS itself learning about them.
context.session is the carrier for one conversational session — the
unit of "this wake-word interaction" or "this HiveMind client
connection". OVOS-MSG-1 makes two of its keys normative:
session.session_id— string identifier; the value"default"is reserved and means originates from the device itself (used byovos-audioto keep TTS local).session.lang— BCP-47 tag for the user's preferred output language (distinct fromdata.langwhich describes the payload).
The rest of session's shape is opaque to this Message — full Session
structure is the job of the consumer (typically ovos-bus-client's
Session class or a future session spec).
Absent session = session_id: "default". A Message with no
session in its context is treated the same as one carrying the
reserved value. Producers don't have to emit a session for
device-local messages.
Each derivation returns a new Message with the right routing/session
fields for its role. The runtime class is preserved — subclasses of
Message get back instances of their own subclass.
Keeps context (including source, destination, and session)
exactly as it was. The forwarder does not become the new source;
the original producer remains named.
ack = m.forward("ovos.utterance.handled", {"id": "u-7"})
# ack.context == m.context (deep-copied)Copies context, then swaps the routing keys so the new Message is
addressed back to the original producer:
- the new
destinationis the oldsource; - the new
sourceis the olddestination(the first entry if it was an array — exact choice is implementation-defined).
Other context keys, including session, pass through unchanged. The
optional context argument is overlaid before the swap, matching
ovos-bus-client.Message.reply behaviour:
ack = m.reply("speak", {"utterance": "got it"})
# ack.context["source"] == m.context["destination"]
# ack.context["destination"] == m.context["source"]A response is a reply whose topic is the source topic with
.response appended. Used by request/response chains so an observer
can recognize the answer:
res = Message("ovos.intent.list").response({"intents": ["..."]})
res.msg_type # 'ovos.intent.list.response'serialize() produces a single UTF-8 JSON object per OVOS-MSG-1
§6:
wire = m.serialize()
recovered = Message.deserialize(wire)
assert recovered == mNested objects in data / context that expose a .serialize()
method (the duck-typed protocol used by ovos-bus-client.Session and
similar carriers) are converted before JSON encoding — Message
itself doesn't know about Session or any specific carrier type, but
its .serialize() walks containers and calls the method when present.
For a JSON-decoded view without the string intermediate, m.as_dict
returns the same envelope as a dictionary:
m.as_dict # {'type': 'ovos.test', 'data': {...}, 'context': {...}}deserialize rejects malformed payloads as MalformedMessage:
unparsable JSON, non-object root, unknown top-level keys, missing
type, wrong value types. Both str and bytes are accepted, and an
already-parsed dict short-circuits the JSON step.
There is no per-message identifier, no in-reply-to chain, no
host-managed request/response bookkeeping. Messages on the bus are
fully asynchronous. The .response suffix and the preserved session
give you enough raw material to do your own correlation if you need
it — match an incoming <topic>.response against an outstanding
request in the same session_id.
Components that need per-conversation state track it themselves keyed
on session.session_id. A Message on the bus is self-contained;
any state a later consumer needs is either inside the Message or kept
by some component out of band — never recovered by a hidden host-side
correlation index.
By design, no transport (websocket, queue, …), no encryption, no
authentication, no delivery guarantees, no ordering guarantees, no
session lifecycle, no identifier-assignment policy. Each of those is
out of scope for the envelope and belongs in the layer that consumes
it — ovos-bus-client for the websocket transport, layer-2 systems
(HiveMind) for multi-tenant routing, individual subsystems for their
own per-session state.
Message is designed to be subclassed. Both deserialize (a
classmethod) and forward / reply / response return instances of
the runtime class, so a transport-layer subclass propagates through
derivation chains without losing identity:
class TransportMessage(Message):
def serialize(self):
body = super().serialize()
# add framing, encryption, … here
return body
m = TransportMessage("ovos.test")
forwarded = m.forward("ovos.next")
assert isinstance(forwarded, TransportMessage)This is exactly how ovos-bus-client.Message is expected to extend
the spec primitive once it adopts this class.
Every part of the Message API traces to a specific OVOS-MSG-1
clause. The module's docstrings cite these inline; this table is the
quick index.
| API surface | OVOS-MSG-1 § | What it implements |
|---|---|---|
Message(type, data, context) |
§2, §2.1–§2.3 | the three-field envelope; constructor type checks |
MalformedMessage (constructor) |
§2.1–§2.3 | reject non-string type, non-dict data/context |
msg_type / data / context |
§2 | the wire fields type / data / context |
context["source"] / ["destination"] |
§3, §3.2–§3.4 | opaque routing keys, carried not parsed |
context["session"] |
§4 | session carrier (inner shape owned by OVOS-SESSION-1) |
DEFAULT_SESSION_ID |
§4.1 | the reserved "default" session id |
forward(type, data) |
§5.1 | relay, context preserved unchanged (deep-copied) |
reply(type, data, context) |
§5.2 | swap routing keys, address back to the asker |
response(data, context) |
§5.3 | .response-suffixed reply |
| (no correlation API) | §5.4 | messages are fully async; no host-side correlation |
serialize() |
§6 | single UTF-8 JSON object, finite numbers only |
as_dict |
§2, §6 | JSON-decoded envelope view |
deserialize(payload) |
§6, §7 | parse + reject malformed payloads |
MalformedMessage (deserialize) |
§7 | the consumer "MUST reject" rules |
- Bus namespaces —
SpecMessage,MIGRATION_MAP, and the transparent legacy↔ovos.*bridge that rides on top of these derivations (forwardfor the handler-lifecycle trio,replyfor the stop pong, and so on). - Spec traceability — every public symbol in
ovos-spec-toolsmapped to its authoritative spec section.