Skip to content

Latest commit

 

History

History
1835 lines (1540 loc) · 87.1 KB

File metadata and controls

1835 lines (1540 loc) · 87.1 KB

Utterance Lifecycle and Pipeline Specification

Spec ID: OVOS-PIPELINE-1 · Version: 2 · Status: Draft

This document defines the utterance lifecycle — the path an utterance takes from the moment it enters the assistant to the moment the assistant is done with it — and the pipeline plugin abstraction the orchestrator runs to decide what to do with each utterance.

This is the foundational bus specification for voice assistant input/output: it defines the natural-language entry point (ovos.utterance.handle, §9.1), the pipeline plugin abstraction the orchestrator iterates, and the natural-language exit point (ovos.utterance.speak, §9.6). Intent registration and skill dispatch are an optional layer built on top of this mechanism.

It builds on two companion specifications:

  • the Bus Message Specification (OVOS-MSG-1) — the envelope, routing keys, session carrier, and derivations every Message defined here travels in;
  • the Intent Definition Specification (OVOS-INTENT-3) — defines the orchestrator and the intent / handler model.

See also: OVOS-INTENT-4 (Intent and Entity Registration Bus Contract) — the wire format pipeline plugins MAY consume to learn what intents skills have registered. Consumption is plugin- discretionary; this specification does not require it.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, RECOMMENDED and MAY are used as in RFC 2119.


1. Scope

This specification defines:

  • the pipeline plugin abstraction (§3) — the only thing the orchestrator iterates;
  • the match contract (§4) — the only thing a plugin exposes;
  • the session fields owned by this specification (§5): session.pipeline (positive whitelist + ordering), session.blacklisted_pipelines, session.blacklisted_skills, session.blacklisted_intents (negative filters);
  • the utterance lifecycle (§6) — entry, iteration, dispatch, terminal events;
  • the dispatch topic shape (§7) — <skill_id>:<intent_name>;
  • the handler-lifecycle trio (§8) — ovos.intent.handler.start / .complete / .error;
  • the utterance-layer bus events (§9) — the utterance entry topic ovos.utterance.handle (§9.1), ovos.intent.matched, ovos.intent.unmatched, ovos.utterance.handled, and the natural-language response topic ovos.utterance.speak (§9.6);
  • conformance (§11).

It does not define:

  • what any pipeline plugin actually does — plugins are black boxes identified by an opaque pipeline_id. The orchestrator's only contract with a plugin is the match operation of §4. Whether a plugin matches by template intents, keyword intents, a fine-tuned classifier, a chatbot, a language model, or anything else is the plugin's business.
  • what any handler does — handlers are black boxes. Skills run their own; plugins that bundle handlers run theirs. The bus observes the handler-lifecycle trio (§8) and that is the full observable contract.
  • how plugins are loaded, discovered, configured, or instantiated — a deployment concern.
  • how plugins consume registrations — OVOS-INTENT-4 puts registrations on the bus; whether and how a given plugin subscribes is the plugin's own business.
  • the session lifecycle — session is carried opaquely per OVOS-MSG-1 §4. The session fields this spec owns are listed in §5; other internal fields are owned by other specifications via the OVOS-SESSION-1 §2.2 registry mechanism.
  • per-plugin behavioural specs — plugins have no behavioural contract beyond §4. A converse plugin, a fallback plugin, a persona plugin, a language-model plugin, a chatbot plugin: each defines itself.

2. The orchestrator and the pipeline plugin

The orchestrator (OVOS-INTENT-3 §6.1) is the logical role that consumes the utterance-layer entry topic ovos.utterance.handle (§9.1), iterates plugins per session, emits dispatch and terminal events, and guarantees the universal end-marker ovos.utterance.handled. The orchestrator is distinct from the messagebus (the transport layer) and from any individual plugin.

The orchestrator MAY be implemented as a single process or as multiple cooperating processes — a natural split along the audio boundary runs an audio-input service (mic, STT), an utterance- handling service (the pipeline and intent matching specified here), and an audio-output service (TTS, playback) as separate processes. From this specification's perspective those processes together are "the orchestrator"; the split is a deployment / containerization choice the spec accommodates but does not prescribe. Pipeline plugins, the loaded-plugin set, and the match contract of §4 live in the orchestrator process that implements the utterance lifecycle (the utterance-handling service in the split shape above).

The orchestrator is stateless for named sessions and holds persistent state only for the reserved session_id == "default" (OVOS-SESSION-1 §3.1). The full state-ownership model is owned by OVOS-SESSION-2; consumers of this spec MAY take it as a working assumption that each inbound utterance brings its own session and the orchestrator does not maintain cross-utterance state for named sessions.

A pipeline plugin is a third-party component identified by an opaque pipeline_id — an arbitrary, deployment-unique string. The orchestrator loads some number of plugins at startup; how it discovers and instantiates them is a deployment concern. Each plugin exposes one operation to the orchestrator (§4) and is otherwise a black box.


3. Pipeline plugins

A pipeline plugin is identified by an opaque pipeline_id — an arbitrary string. The orchestrator's loaded-plugin set is a mapping pipeline_id → plugin instance; the orchestrator does not interpret the pipeline_id string beyond using it as a key.

Constraints on pipeline_id strings:

  • Non-empty.
  • Bound by OVOS-MSG-1 §2.1.1: pipeline_id appears as a component in colon-separated topic shapes (<skill_id>:<intent_name> in §7) and as a component in the dot-separated introspection topics of §10.1 (ovos.pipeline.<pipeline_id>.intents.list). A pipeline_id MUST therefore be composed only of ASCII letters, digits, _ and - — the character set [A-Za-z0-9_-]. Neither : nor . may appear: a : breaks the single-colon dispatch split of §7, and a . breaks the segment split of the §10.1 topic shape. A pipeline_id outside this character set is malformed; the orchestrator MUST NOT load a plugin under it and SHOULD log the rejection.
  • Unique within a deployment's loaded-plugin set.

A deployment MUST refuse to load a second plugin declaring a pipeline_id already held by a loaded plugin, and MUST report that refusal in a way the deployer can observe. The first plugin loaded under the id MUST stay loaded and continue serving requests. A session.pipeline entry naming that id refers to the plugin that is loaded, never to the refused one.

A plugin instance has exactly one pipeline_id. That identifier is the plugin's actor identity, and it lives in the same namespace as a skill_id — the two are indistinguishable by construction, which is exactly what makes the uniform dispatch of §7.0 work. A plugin never carries a second or alternate identifier.

An entry in session.pipeline is a reference to a matcher, not an actor. A plugin that offers several match configurations — a strict mode and a permissive mode, confidence tiers, a range-restricted variant — is referenced by several entries, and every one of them resolves to the same plugin instance and the same pipeline_id. Match configurations have no identity of their own: they are configuration, not participants on the bus.

How a deployment names a match configuration in session.pipeline, and how the orchestrator resolves an entry to a (plugin, configuration) pair, is deployment configuration and is not fixed here. What is fixed is the attribution: the dispatch topic (§7), Match.skill_id for a self-matching plugin (§7.0), the session.active_handlers stamp (§7.1), the introspection topic (§10.1) and a blacklisted_pipelines entry (§5.2) all name the plugin's single pipeline_id — never an entry-specific string.

3.1 Pipeline attribution

The orchestrator stamps context["pipeline_id"] on the dispatch Message (§7.1) — this is the first point at which the matching plugin's identity appears on the wire. From there it propagates through all Messages the handler emits via MSG-1 derivation semantics, making every downstream Message attributable to the plugin that produced the match without any further action by the plugin or the handler.


4. The match contract

A plugin exposes one operation to the orchestrator:

match(utterances, lang, session) → Match | None

Inputs:

  • utterances — a non-empty list of candidate strings. The list typically originates from the entry topic (§9.1) and may have been modified by the utterance-transformer chain (OVOS-TRANSFORM-1 §3.2) before reaching the plugin. A plugin MUST accept this shape: a list of one or more candidate transcripts, in no particular order, all in the same language. A plugin is free to consider all candidates, only the first, or any subset; the orchestrator does not prescribe how candidates are weighted.
  • lang — the BCP-47 content-language tag, resolved exactly once per utterance by the precedence of OVOS-SESSION-1 §3.2.7. The orchestrator passes the resolved tag to every plugin's match call for that utterance; resolution always terminates in a tag, so the lang argument is never absent and never empty, and a plugin MUST NOT be called with an unresolved language. A single resolution point keeps the match round coherent: if each plugin re-derived language independently, the same utterance could be matched in different languages at different pipeline stages, and which language "wins" would be an accident of ordering. A plugin MAY refine the received tag (e.g. a multilingual matcher that detects a different content language) but MUST NOT re-derive it independently from session evidence, and MUST declare the language it actually matched in via Match.lang.
  • session — the session carrier from context.session of the utterance Message (OVOS-MSG-1 §4, OVOS-SESSION-1).

Output: either None (decline) or a Match object with the fields below.

4.1 The Match shape

Field Type Required Meaning
skill_id string yes The skill_id of the handler to invoke. For a pipeline plugin that matches itself, this equals its pipeline_id (§7.0).
intent_name string yes An opaque non-empty string that, together with skill_id, names the handler to invoke. For skill-owned matches this is the intent name the skill registered. For plugin-owned matches this is whatever label the plugin chose for this response.
lang string yes The BCP-47 language tag the match was performed against. The plugin MUST set this — it is the plugin's explicit responsibility to declare what language its match is in. A plugin that received a lang parameter and matched in that language returns it here; a plugin that determined the language by other means (multilingual matcher, hard-coded engine, content-language detection) sets it to whatever language the match was performed in.
slots object (string→string) yes The slot map (§4.3). MAY be empty.
utterance string yes The specific candidate string from the input list that won the match. A plugin that does not track which candidate won MUST populate this with the first element of the input list as a fallback; the orchestrator forwards this value verbatim as data.utterance in the dispatch payload (§7.1) and MUST NOT substitute another value.
updated_session object no A replacement session snapshot the plugin produced during match (§4.2). When present, the orchestrator MUST use this snapshot — in place of the inbound utterance's session — for the dispatch and every downstream stage. When absent, the inbound session is carried unchanged. This is the only mechanism by which a plugin's match-phase session mutations reach downstream consumers; in-place mutations of the inbound session object are not visible past the plugin boundary.

Malformed Match objects. A returned Match is malformed when any of the following holds:

  • a field marked required in the table above is missing, null, or of the wrong type;
  • skill_id or intent_name is empty, or contains : — either breaks the single-colon split of the <skill_id>:<intent_name> dispatch topic (§7, OVOS-MSG-1 §2.1.1);
  • lang is empty or is not a BCP-47 language tag;
  • slots is not a mapping of strings to strings (§4.3);
  • utterance is empty.

The orchestrator MUST treat a malformed Match exactly as if the plugin had declined: log the defect, continue iteration to the next plugin per §6.2, and carry the inbound session unchanged — a malformed Match never carries an updated_session into the lifecycle. No bus event is emitted; the rejection is observable only as a non-match. The orchestrator MUST NOT repair a malformed Match by substituting a value of its own, because every repair guesses at a routing decision the plugin failed to state.

The orchestrator interprets a well-formed non-None return as a definitive claim. It does not score, rank, or rerank matches across plugins — first match wins (§6). A plugin that wants to express uncertainty must return None and let a later plugin claim.

4.2 The match contract is the single obligation

The plugin's match operation has one obligation: return a Match (§4.1) or null. The orchestrator does not constrain anything else about what match does internally — emitting bus Messages during match is allowed (a plugin that polls other components, calls out to a model server, asks the user a disambiguation question, or runs any other matching strategy that requires bus communication is conformant), and side effects on plugin-internal state are the plugin's own business.

Session mutation via Match.updated_session. A plugin MAY mutate session state as part of producing a Match — for example, an intent plugin setting session.intent_context on the match dispatch, a converse plugin setting session.response_mode because the matched intent enables a follow-up wait window, or any plugin reordering session.pipeline for subsequent utterances on this session. The mutation is communicated to the orchestrator via the updated_session field on the returned Match (§4.1): the plugin populates updated_session with the new session snapshot it wants downstream consumers to see, and the orchestrator MUST use that snapshot for the dispatch and every subsequent stage of the utterance lifecycle (§6). When updated_session is absent, the inbound utterance's session is carried unchanged. An updated_session names the round's own session: its session_id equals the session id the orchestrator retrieved at intake (§6.1). An updated_session carrying a different session_id is a plugin fault — the orchestrator MUST ignore it, log the fault, and continue the round on its own working session.

The updated_session pathway is only effective for a claiming match. A plugin that returns null (declines) does not return any Match, and therefore any session mutation it performed during its match call is discarded at the plugin boundary: the orchestrator continues iteration with the inbound session snapshot, untouched. This is what makes match-phase mutation safe under §6.2 first-match-wins iteration — a declined plugin's exploratory mutations never reach later plugins or downstream stages.

The orchestrator-side pattern is uniform:

match = plugin.match(utterances, lang, session)
if match is not None:
    session = match.updated_session or session
    # dispatch and downstream stages use this session

A plugin that mutates the inbound session object in place during match without populating updated_session is non-conformant — the in-place mutation may or may not be visible to the orchestrator depending on object identity, and the field is the only guaranteed-visible match-phase channel. Plugins that need to mutate session state from match MUST do so via a fresh snapshot returned in updated_session, not via in-place mutation.

This rule applies only to match-phase mutations. Session mutation from handlers (under §7 dispatch), from transformers (OVOS-TRANSFORM-1 §3), and from the direct session-mutation pathway of OVOS-MSG-1 (which CONTEXT-1 §5.3 and CONVERSE-1 §3.2 build on) is governed by those specs and is unaffected by §4.2.

4.3 The slot map

Match.slots is a {string: string} mapping (the same shape OVOS-INTENT-3 §7 defines for template / keyword intent slots).

For skill-owned matches against intents the plugin previously consumed from OVOS-INTENT-4 registrations, the slot map keys are the slot names (template intents) or vocabulary names (keyword intents) of the matched intent.

For plugin-owned matches, the slot map is whatever the plugin chooses to surface. It MAY be empty.

The orchestrator does not interpret the slot map; it forwards it to the dispatched handler.

Slot values stay strings even for a typed slot (OVOS-INTENT-1 §5.6). When a data.typed_slots map is present after the typed-slots stage, the orchestrator forwards it on the dispatch Message (§7.1), so a handler that wants a normalized value looks up the entry whose surface equals the slot value rather than expecting a non-string in slots.

4.4 Match-phase timeout and latency discipline

The match operation is logically synchronous from the orchestrator's perspective — the orchestrator calls match and waits for the return value. Because §4.2 permits a plugin to communicate over the bus during match (poll a model server, ask the user a disambiguation question, etc.), the call can block for an unbounded time.

The orchestrator SHOULD bound each match invocation by a deployment-defined time. The RECOMMENDED default is 10 s.

Sizing that bound is a deployment obligation, not an orchestrator one: only the deployer knows the internal collection ceilings of the plugins it loaded, and the orchestrator treats every plugin as a black box (§1). A deployment MUST set each plugin's match-phase bound at or above any collection ceiling that plugin runs internally — for common query, at or above the §7.2 collection window ceiling of OVOS-COMMON-QUERY-1, as that specification's §2.1 requires. A bound shorter than a stage's own internal wait guarantees the stage is killed mid-collection on every utterance it handles.

If a plugin has not returned within the bound, the orchestrator MUST treat the call as if the plugin had raised an exception — log the timeout, skip to the next plugin per §6.2, and continue normally. Any partial mutation performed by the plugin during the timed-out call is discarded (there is no Match to carry an updated_session; the inbound session is unchanged). No bus event is emitted for the timeout at this stage.

A timed-out call is closed for good. Once the bound has expired and iteration has moved on, the orchestrator MUST ignore any Match the abandoned call later produces: it MUST NOT dispatch it, MUST NOT commit its updated_session, and MUST NOT re-enter the match round. Without this rule a slow plugin that returns just after a later plugin claimed would dispatch a second handler for one utterance and break the one-ovos.utterance.handled invariant of §9.5. Whether the orchestrator can actually cancel the abandoned call is out of scope — the orchestrator is bound to ignore the result, not to stop the work. Plugins SHOULD therefore tolerate having a completed match discarded, and SHOULD NOT treat the return of match as a commitment that the dispatch will follow.

The timeout bound and whether it counts toward the §6.2 circuit-breaker are deployer-configurable.

Latency discipline. In voice-assistant deployments, match-phase latency directly determines response latency — the pipeline is sequential and the user is waiting. Plugins SHOULD therefore return from match as quickly as possible and defer all long-running work to the handler phase. A plugin that can determine it will claim an utterance without fully processing it SHOULD return a Match immediately and begin expensive processing (model inference, network calls, disambiguation) inside the handler, not inside match.

A language-model plugin is the canonical example: it typically knows it will consume any utterance that reaches it and can return a Match immediately; the actual generation belongs in the handler. The match phase is a routing decision, not a processing phase.

The orchestrator SHOULD surface match-phase duration as an observable metric so deployers can identify plugins that violate this discipline.

4.5 The candidate poll

Several matching strategies cannot decide from the utterance alone. The plugin must ask a set of candidates whether any of them claims the utterance, and decide from the answers. This section defines that exchange once. A specification that uses it names its own topics and its own candidate set, states what a claim means there, and gives the rule that orders the claimants. Everything below is common to all of them.

Ping. The plugin broadcasts one Message per round, whatever the size of the candidate set — one question, one collection window, never a sum of per-candidate waits. The ping is derived from the inbound utterance Message through the reply derivation (OVOS-MSG-1 §5.2), so that context.session, the routing keys and the lifecycle identifier propagate with no plugin action, and the ping reaches a candidate whether it runs locally or behind a satellite transport. The ping topic is a static string fixed by the protocol that defines it (OVOS-MSG-1 §2.1.1) and names no candidate. A poll is not a §7 dispatch: it does not use the <skill_id>:<intent_name> topic shape, it does not fire the handler-lifecycle messages of §8, and it does not activate any candidate.

Pong. A candidate answers on the protocol's static pong topic, with a Message derived from the ping through the same reply derivation, so that the answer reaches the plugin wherever the candidate runs. source and destination are layer-2 metadata and do not affect the topic name. The payload carries the answering candidate's identity and its claim:

Field Type Required Meaning
skill_id string yes The candidate answering — identity is payload, never topic.
can_handle boolean yes true claims the utterance, false declines it.

A protocol MAY add fields of its own to either payload. Identity is the payload skill_id, verified against the round's candidate set — never a skill_id inferred from derivation metadata, which carries no identity for the responding component (OVOS-MSG-1 §5.2).

Round correlation. The round is the lifecycle. The plugin keys poll state by session_id from context.session and by context.utterance_id (§9.1.1), which the ping carries by reply derivation and the pong carries back the same way, so a poll protocol adds no correlation field of its own. A pong whose utterance_id or session_id does not equal the round's MUST be discarded — an answer that cannot prove which question it answers never decides a round.

Validity, silence and duplicates. A pong is valid only when it carries a skill_id string, a can_handle JSON boolean, and the round's correlation. A plugin MUST treat as can_handle: false both a candidate silent at window close and a pong whose can_handle is absent or is not a boolean. A truthy non-boolean value MUST NOT be coerced to true. A plugin MUST ignore a pong whose skill_id names no member of the round's candidate set, and any pong arriving after the window has closed. Where one candidate answers twice in a round, the first valid pong wins.

A pong whose skill_id names a skill listed in the round's session.blacklisted_skills (§5.3) MUST be discarded before selection, exactly as a pong from outside the candidate set is. Where the protocol carries an intent identity on the pong, a pong naming an intent listed in session.blacklisted_intents (§5.4) MUST be discarded on the same terms.

The window. A plugin MUST bound collection by one deployer-configurable window per round, and the protocol using this section states its own RECOMMENDED default. Without a bound, one unresponsive candidate stalls the utterance's serial critical path indefinitely. An explicit decline is what lets the window close early rather than waiting the bound out, so a candidate SHOULD answer even when it declines. The window SHOULD close early once the outcome is forced: every candidate has answered, or the best claim so far cannot be outranked by any candidate still unanswered under the protocol's ordering rule. A poll is a deliberate exception to the latency discipline of §4.4, and the bound together with the early close is what keeps it one.

That window is the stage's collection ceiling whatever the candidate count, and a deployment MUST set the stage's §4.4 match bound at or above it. A shorter bound kills the stage mid-collection on every utterance it handles.

Ordering. Pongs arrive in whatever order candidates answer, and selection MUST follow the ordering rule of the protocol running the poll rather than arrival order. A fast claim from a low-ranked candidate never beats a slow claim from a higher-ranked one inside the window.


5. Session fields owned by this specification

This specification claims four session fields per OVOS-SESSION-1 §2.2: one positive ordering field (§5.1 pipeline) and three negative filtering fields (§5.2 blacklisted_pipelines, §5.3 blacklisted_skills, §5.4 blacklisted_intents). All four are session-scoped, propagate with the session under OVOS-SESSION-1 §4, and follow the deployment-default-fallback absence rule of OVOS-SESSION-1 §2.1: an omitted, empty, or absent field resolves at consumption to the deployment default for that field.

For session.pipeline that deployment default has one name in this specification — the default-session pipeline, defined once in §5.1 as the pipeline configured for the reserved session_id == "default" session (OVOS-SESSION-1 §3.1). Every later mention of a default pipeline in this specification means that list and no other.

5.1 session.pipeline

An ordered array of pipeline_id strings expressing the session origin's preference for which plugins to run and in what order. It is a preference, not an authorization: the orchestrator narrows the requested list to what is loaded (below) and what policy permits (§5.5).

Any session — local, remote, layer-2-attached, programmatic — MAY populate session.pipeline to request a specific ordering. The orchestrator does not interpret who set it; the field is a preference channel.

Example:

{
  "session": {
    "pipeline": [
      "template-high",
      "keyword-high",
      "template-medium",
      "keyword-medium",
      "common-qa",
      "persona-high",
      "fallback-low"
    ]
  }
}

For each utterance, the orchestrator narrows this list into the effective pipeline (§5.5) and iterates that list in order, calling match on each corresponding plugin (§6.2).

If a pipeline_id in session.pipeline does not correspond to any loaded plugin, the orchestrator MUST skip it and SHOULD log a warning. It MUST NOT abort the utterance over an unknown identifier and MUST NOT fall back to the deployment default merely because one identifier is unknown — the remaining known identifiers are the effective ordered set.

If session.pipeline is absent or empty (per OVOS-SESSION-1 §2.1), the orchestrator falls back to the default-session pipeline: the pipeline configured for the reserved session_id == "default" session (OVOS-SESSION-1 §3.1). The default-session pipeline is owned and maintained by the orchestrator and represents what the deployment runs when no preference is expressed. If the default session itself has no pipeline configured, the utterance proceeds to no-match (ovos.intent.unmatched, §9.3).

Different sessions may carry different pipeline. This is how a session origin expresses different preferences for different participants — for example, a remote-peer session may request a restricted pipeline tailored to that participant's needs. Whether that preference is honoured is a policy decision (§5.5).

5.2 session.blacklisted_pipelines

An unordered array of pipeline_id strings the orchestrator MUST NOT invoke for this session.

blacklisted_pipelines is the policy channel for pipeline selection. Where session.pipeline (§5.1) is the session origin's preference, blacklisted_pipelines is enforcement: a plugin listed here MUST NOT be invoked for this session even if the same pipeline_id is requested in session.pipeline. Policy overrides preference (§5.5).

Filtering is orchestrator-only: when the orchestrator iterates its effective pipeline (per §5.5), it MUST skip any pipeline_id listed here as if it were not loaded. No match call is made; no bus event is emitted for the skip. The filtering is observable only as a non-invocation.

Entries here name plugins, not session.pipeline entries (§3). Denying a pipeline_id therefore removes every entry that resolves to that plugin, whichever match configuration the entry selects: a plugin cannot be denied in one tier and invoked in another, because there is only one actor to deny.

Unknown pipeline_ids in blacklisted_pipelines are harmless and MUST NOT cause the utterance to abort — they simply match nothing.

An empty array ([]) is wire-equivalent to omission: both fall back to the deployment default per OVOS-SESSION-1 §2.1. A producer with no pipelines to deny SHOULD omit the field rather than emit [], per the wire-weight guidance of OVOS-SESSION-1 §3.4.

5.3 session.blacklisted_skills

An unordered array of skill_id strings (OVOS-INTENT-3) whose intents MUST NOT be matched for this session.

The contract is two-tier:

  1. Plugin-side filtering is an optimisation. A pipeline plugin SHOULD NOT return a Match whose skill_id (§7.1) is a skill_id listed here. A plugin's internal handling of would-match-but-blacklisted candidates is not specified — it MAY skip the candidate before scoring, suppress its score below a match threshold, route to a plugin-internal default-handler, or anything else — as long as the returned Match does not name a blacklisted skill. Filtering early lets the plugin claim the utterance with its next-best candidate instead of losing the turn to a later stage, which is why the field is worth honouring; a plugin that ignores it is still conformant with this specification, because the field's enforcement does not depend on it.
  2. Orchestrator-side filtering is the enforcement. After a plugin returns a candidate Match, the orchestrator MUST check Match.skill_id against blacklisted_skills and, if listed, MUST treat the match as if the plugin had declined — continue iteration to the next plugin per §6.2. No bus event is emitted for backstop filtering; it is observable only as a non-match, though the orchestrator MAY also emit ovos.policy.denied (§9.7) to make it observable. This check runs on every candidate Match from every plugin, so the denylist holds across the whole loaded-plugin set whatever any individual plugin does. The authorization model of §5.6 rests on this tier alone.

Empty-array semantics match §5.2: [] is wire-equivalent to omission. A producer with no skills to deny SHOULD omit the field.

5.4 session.blacklisted_intents

An unordered array of fully-qualified <skill_id>:<intent_name> strings (the dispatch-topic shape of §7) whose specific intents MUST NOT be matched for this session.

The contract is identical in shape to §5.3 — plugin-side filtering is an optimisation the plugin SHOULD perform, and the orchestrator's per-candidate check is the enforcement it MUST perform — with the comparison performed against the candidate Match's dispatch identity <Match.skill_id>:<Match.intent_name>.

As with §5.3, the orchestrator MAY also emit ovos.policy.denied (§9.7) to make a blacklisted_intents denial observable.

The bare intent_name form is not accepted in this field. intent_name is only unique within an owner, so a bare entry would silently denylist every same-named intent across every skill and every pipeline plugin in the deployment — a sharp footgun. A producer MUST emit fully-qualified entries; a consumer MAY reject malformed (non-colon-bearing) entries or MAY ignore them silently, but MUST NOT broaden a bare entry to all owners.

Entries are language-agnostic. OVOS-INTENT-4 §3.2 keys intent identity on the triple (skill_id, intent_name, lang), so a single intent registered for en-US and de-DE is two separate registrations. A blacklisted_intents entry <skill_id>:<intent_name> denies both — there is no per-language denylist. A deployment that needs language-scoped denial expresses it through a session whose lang already narrows the set of matchable registrations.

Empty-array semantics match §5.2: [] is wire-equivalent to omission. SHOULD-omit when there is nothing to deny.

5.5 Composition: preference, availability, policy

The four fields layer in a fixed order: a preference stage (§5.1), an availability stage (the loaded-plugin set), and a policy stage (§5.2 / §5.3 / §5.4). Each later stage may narrow the result of the earlier ones; no later stage adds anything an earlier stage rejected.

The orchestrator computes the effective pipeline for an utterance:

  1. Preference. Start from session.pipeline if set and non-empty; otherwise start from the default-session pipeline (§5.1).
  2. Availability. Drop any pipeline_id that does not correspond to a plugin loaded by the orchestrator. Unknown identifiers do not abort the utterance and do not trigger fallback to the default-session pipeline — the remaining known identifiers are the effective ordered set (§5.1).
  3. Policy. Drop any pipeline_id listed in session.blacklisted_pipelines, even if it was explicitly requested in step 1. Policy overrides preference.

The result is the ordered list of pipeline_ids the orchestrator iterates for this utterance.

session.blacklisted_skills and session.blacklisted_intents are not applied at this stage. They are per-candidate policy filters applied during iteration against each Match a plugin returns (§5.3, §5.4). The two-tier shape — plugin-side filtering as an optimisation, orchestrator-side filtering as the enforcement — ensures policy holds regardless of what any plugin does. The orchestrator also re-imposes every per-component override field (OVOS-SESSION-1 §3) onto any updated_session a plugin returns (§4.2), from the value the orchestrator held before the plugin ran, so a claiming plugin cannot relax or redirect policy for the stages after it. This class is deployment-owned in its entirety, not by naming individual members here.

The intended separation of concerns is sharp:

  • Any session origin — including the participant on the user side of the bus — MAY request a preferred pipeline via session.pipeline. This is a request channel, available to every emitter without authorization.
  • Only policy (the denylists, typically populated by the orchestrator owner or by a layer-2 substrate that owns the session, see §5.6) can refuse a request. Policy is enforcement; preference is request. The two fields are layered, not alternatives.

If every requested pipeline_id is dropped by availability or policy, the effective pipeline is empty and the utterance proceeds directly to no-match (ovos.intent.unmatched, §9.3). The orchestrator MUST NOT silently fall back to the default-session pipeline in this case — falling back would let a policy-rejected preference pull in a different ordering the origin never asked for and policy never approved.

5.6 Use under layer-2 substrates (informative)

The §5.5 layering — preference from any origin, enforcement from policy — is precisely what a layer-2 substrate (per OVOS-MSG-1 §3.4 / §4.2) needs to express granular per-peer permissions in a multi-tenant deployment, without inventing a separate authorization channel.

The intended split:

  • A client (the participant on the user side of the bus — local device, remote peer, satellite, programmatic caller) sets session.pipeline to request what it would like to run. Clients are not trusted to grant themselves capabilities; they are only stating a preference.
  • A layer-2 substrate that owns the session (typically because it attached the per-peer session at connection time) populates session.blacklisted_pipelines, session.blacklisted_skills, and session.blacklisted_intents from the peer's permission grant. These ride on every derived Message through OVOS-SESSION-1 §4 propagation, so no per-hop re-authorization is needed and no orchestrator-side change is required to add authorization.

The orchestrator enforces the intersection: §5.5 step 3 drops disallowed pipelines from the request; §5.3 / §5.4 drop disallowed matches per candidate. A client that requests a forbidden plugin or intent simply gets no result for that part of its request — its preference is silently narrowed, exactly as if the plugin were not loaded.

This specification reserves no fields for layer-2 authorization beyond the three denylists; the broader authorization model (identity verification, peer-to-grant binding, revocation, auditing) is the layer-2 substrate's concern, not PIPELINE-1's.


6. The utterance lifecycle

Every utterance flows through the same lifecycle, regardless of which plugin (if any) claims it. The lifecycle is guaranteed to terminate with exactly one ovos.utterance.handled event (§9.5).

6.1 The flow

ovos.utterance.handle                    ← entry (§9.1)
   │
   ├─ session retrieval; language resolved once (§4)
   │
   ├─ utterance-transformer chain runs   ← TRANSFORM-1 §3.2
   ├─ metadata-transformer chain runs    ← TRANSFORM-1 §3.3
   ├─ typed-slots stage runs             ← TRANSFORM-1 §3.7
   │
   ├─ effective pipeline composed (§5.5)
   │  (preference → availability → policy)
   │  — composed AFTER the metadata chain, so a metadata
   │    transformer rewriting session.pipeline routes THIS
   │    utterance
   │
   ├─ pre-match context prune            ← CONTEXT-1 §4
   │  (drop entries no longer live; this is the snapshot
   │   every plugin sees for this match round)
   │
   ├─ for pipeline_id in effective pipeline:
   │     plugin = loaded_plugins[pipeline_id]     # skip if not loaded
   │     match = plugin.match(utterances, lang, session)
   │     if match is None:
   │         continue   # any plugin-side updated_session is discarded
   │
   │     orchestrator-backstop denylist check (§5.3/§5.4)
   │     orchestrator-backstop required_slots check (§6.2)
   │     if filtered:  continue
   │
   │     session = match.updated_session or session   # §4.1, §4.2
   │
   │     ── match round closes here ──
   │     post-match decrement turns_remaining--   ← CONTEXT-1 §4
   │
   │     ┌── post-match-pre-dispatch window ──────────────┐
   │     │ engine-side context promotion (CONTEXT-1 §5.1) │
   │     │ intent-transformer chain runs (TRANSFORM-1     │
   │     │   §3.4) — may modify Match.slots, MUST NOT  │
   │     │   change skill_id / intent_name                 │
   │     └────────────────────────────────────────────────┘
   │
   │     ovos.intent.matched                  (§9.2)
   │     dispatch on <match.skill_id>:<match.intent_name>  (§7)
   │     (handler runs; orchestrator emits lifecycle trio §8)
   │     ovos.utterance.speak (×0..N)          (§9.6)
   │     ovos.utterance.handled               (§9.5)
   │     break
   │     [output layer — outside this spec's scope]
   │     (dialog-transformer chain ← TRANSFORM-1 §3.5)
   │     (tts-transformer chain   ← TRANSFORM-1 §3.6)
   │
   └─ if no plugin matched (or all matches filtered):
         ── match round closes here ──
         post-match decrement turns_remaining--  ← CONTEXT-1 §4
         ovos.intent.unmatched                  (§9.3)
         ovos.utterance.handled                   (§9.5)

The post-match decrement runs once, on whichever branch the utterance took, at the point the match round closes — after the claiming plugin's Match is committed and before the dispatch goes out, or after the last plugin declined. Both branches close the same round, so both decrement (CONTEXT-1 §4); entries freshly written during this round are exempt per CONTEXT-1 §4.1. Placing the decrement before the dispatch keeps the dispatched handler and every plugin in the round looking at the same turn count.

The flow diagram shows where companion-spec chains plug into this specification's iteration loop. The audio-transformer chain (TRANSFORM-1 §3.1) runs entirely in the audio-input service before the entry topic is emitted and is therefore not visible here. The utterance and metadata transformer chains run after entry and before iteration, against the candidate utterance list. The orchestrator composes the effective pipeline (§5.5) after the metadata chain and MUST NOT compose it earlier: a metadata transformer that writes session.pipeline is performing per-utterance routing, and composing before the chain would defer its decision to the next utterance. The

post-match-pre-dispatch window is where CONTEXT-1 §5.1 sanctions engine-side session.intent_context mutation and where TRANSFORM-1 §3.4 inserts the intent-transformer chain over the chosen Match. ovos.utterance.handled is emitted at handler completion — immediately after ovos.intent.handler.complete (or .error). The dialog-transformer and TTS-transformer chains (TRANSFORM-1 §3.5 / §3.6) run in the output layer after ovos.utterance.handled, just before TTS rendering; they are outside this specification's scope and are not a synchronization barrier for the end-marker. Audio output is fully decoupled from the pipeline: a chat-only deployment receives the same utterance lifecycle and the same end-marker as an audio deployment.

Pseudocode is informative; normative rules are in §§4–9.

6.2 First-match-wins iteration

For each utterance, the orchestrator MUST:

  • run the utterance-transformer and metadata-transformer chains (OVOS-TRANSFORM-1 §3.2, §3.3) before pipeline iteration begins, followed by the typed-slots stage where the deployment runs one (OVOS-TRANSFORM-1 §3.7);
  • if the utterance-transformer chain returns an empty utterance list, skip pipeline iteration entirely and proceed directly to ovos.intent.unmatched (§9.3) — match() is contractually defined over a non-empty list (§4) and the orchestrator MUST NOT invoke any plugin with an empty list. (If the empty list arrived together with cancellation context per OVOS-TRANSFORM-1 §8.1, the cancellation terminal path of §8.2 there takes precedence over no-match here.)
  • compose the effective pipeline (§5.5) after those chains have run, and iterate the effective pipeline in order — not session.pipeline directly, which is a preference and has not yet been narrowed by availability or policy;
  • for each pipeline_id, call match on the corresponding loaded plugin;
  • stop at the first Match that survives the checks below — a returned Match claims the utterance only after it is found well-formed (§4.1), passes the blacklisted_skills and blacklisted_intents checks (§5.3, §5.4), and passes the required_slots check (below). A Match that fails any of them is treated as a declination and iteration continues, so "first match wins" means first surviving match, never merely first non-None return;
  • if no plugin produces a surviving Match, emit ovos.intent.unmatched (§9.3).

Evaluation order is the arbitration model. The orchestrator deliberately does not compare confidence across plugins: a plugin positioned earlier in session.pipeline gets first refusal on every utterance, and the first claim wins. This is what makes stateful interception possible. A plugin's decision to claim may depend not only on the utterance but on session state — a converse plugin claims only when there is an active handler or an open response_mode (a skill in response mode awaiting a reply); a persona plugin only while a persona is active; a media plugin claims resume only while it holds paused media; a stop plugin only when there is something to stop. The same utterance ("yes", "next", "stop", "resume") therefore routes to a different handler depending on the session, and only ordering can guarantee that the stateful interceptor sees it before the general intent engines that would otherwise match the bare words; a ranked model could let a higher-scoring general match steal a turn that belongs to an active handler.

A selective plugin — one with a strict false-positive budget that expects to decline most utterances — is correspondingly expected to be conservative: claim only when both the utterance and its state warrant it, return None otherwise, and trust its position rather than compete on a score. Cross-plugin ranking is not merely omitted: heterogeneous engines (a keyword matcher, a neural classifier, a language model) share no calibrated score space, and a state-derived certainty ("I hold paused media, so resume is mine") is not a quantity a text-similarity score can outbid. Deployers express policy by ordering session.pipeline (§5.1); each plugin decides its own claim from the utterance and the session it was handed (§4.1, §4.2). The two concerns stay separate.

A plugin that raises an exception during match is treated as if it returned None. The orchestrator MUST continue to the next plugin and SHOULD log the exception. A single plugin's bug does not fail the whole utterance.

Repeated-exception circuit-breaker. An orchestrator SHOULD drop a plugin from the effective pipeline after a deployer-tunable consecutive-exception threshold. A dropped plugin behaves as if absent; recovery is a deployment concern. The threshold and scope (per-session or process-wide) are deployer-configurable.

Orchestrator backstop for required_slots. The backstop applies only to a Match whose intent carries an OVOS-INTENT-4 registration row: after a plugin returns such a Match, the orchestrator MUST verify that the match's slot map contains every slot listed in the registered intent's required_slots (INTENT-3 §5.3). The orchestrator obtains this information from the same registration data the plugin consumed — in-process, this is available from the plugin's compiled state or from the orchestrator's own manifest (INTENT-4 §10). If any required slot is absent, the orchestrator MUST treat the match as if the plugin had declined and continue iteration to the next plugin. A Match whose intent has no registration row — a plugin-owned intent or a reserved-name intent — carries no required_slots for the orchestrator to check and MUST pass this backstop unchanged. This check operates after the blacklisted_skills / blacklisted_intents backstop (§5.3, §5.4) and uses the same observable semantics: no bus event is emitted; it is observable only as a non-match.

Neither backstop consults data.typed_slots. Typed slots are an engine-side refinement (OVOS-INTENT-1 §5.6): the orchestrator checks that a required slot is present in the slot map and never that its value parses as the declared type. Judging a value against its type is matching, which belongs to the plugin that claimed the utterance.

The primary obligation to enforce required_slots still lies with the engine during match(). The orchestrator backstop is a second line of defense against engine bugs or plugins that do not implement the rule.

6.3 Plugins do not see each other's matches

A plugin receives the same utterance every other plugin in the pipeline received; it has no access to what an earlier plugin tried or why it declined. Cross-plugin coordination belongs in session (OVOS-MSG-1 §4) or in plugin-side out-of-band state keyed on session.session_id (per OVOS-MSG-1 §5.4 — "no central correlation, no central state").

6.4 Terminal events

Every utterance terminates in exactly one of five ways, each followed by the universal end-marker ovos.utterance.handled:

Outcome Sequence of utterance-layer events
Matched, handler completed ovos.intent.matched → dispatch + start/complete (§8) → ovos.utterance.speak ×0..N → ovos.utterance.handled
Matched, handler raised ovos.intent.matched → dispatch + start/error (§8) → ovos.utterance.speak ×0..N → ovos.utterance.handled
Matched, handler timed out ovos.intent.matched → dispatch + start/error with a timeout exception (§8.3) → ovos.utterance.handled
No plugin matched ovos.intent.unmatched → ovos.utterance.handled
Cancelled by a transformer ovos.utterance.cancelled → ovos.utterance.handled (see OVOS-TRANSFORM-1 §8.2)

The three matched rows differ only in which handler-trio terminal event fires; they are one dispatch path with three endings. The "every utterance terminates with ovos.utterance.handled" invariant holds across all five paths, and §9.5 lists the same five.

6.5 Long-running handlers and nested utterance lifecycles

Handlers are long-running by design. A handler MAY block for an unbounded duration — for example, to run a voice game, a multi-step interaction, or any flow that asks the user one or more follow-up questions. This is not a timeout condition and MUST NOT be treated as one.

When a handler asks the user a question and waits for the reply (entering response mode, OVOS-CONVERSE-1 §5), the following happens on the bus:

ovos.utterance.handle          (original utterance)
  ovos.intent.handler.start    (outer handler)
  ovos.utterance.speak         (handler's question to the user)
  [outer handler blocks]
    ovos.utterance.handle      (user's reply)
      ovos.intent.matched
      ovos.intent.handler.start    (inner :response dispatch)
      ovos.intent.handler.complete
    ovos.utterance.handled     (user's reply — inner lifecycle ends)
  [outer handler unblocks, continues]
  ovos.intent.handler.complete (outer handler)
ovos.utterance.handled         (original utterance — outer lifecycle ends)

The inner utterance is a complete, independent lifecycle: it enters on ovos.utterance.handle, is matched and dispatched by the converse plugin on <skill_id>:response (OVOS-CONVERSE-1 §5), and terminates with its own ovos.utterance.handled. The outer lifecycle's ovos.utterance.handled does not fire until the outer handler returns, which may be after arbitrarily many inner lifecycles.

The "exactly one ovos.utterance.handled per ovos.utterance.handle" invariant (§6.4) applies independently to each entry message. It says nothing about ordering between concurrent or nested lifecycles; interleaved handler trios and end-markers are conformant and expected.

The orchestrator MUST remain able to accept and process new ovos.utterance.handle messages while a handler is running. An orchestrator that blocks the utterance-entry subscription for the duration of a handler invocation will deadlock the first time any handler waits for a user reply. Concurrent utterance processing is a structural requirement, not an optimisation.

The same liveness requirement applies within the match phase: the orchestrator MUST continue servicing its bus subscriptions — including poll replies destined for an in-flight plugin (a stop plugin's pongs, a converse or fallback poll's responses) — while a match call is in flight. An orchestrator whose bus loop blocks on the synchronous match return deadlocks every plugin whose match strategy involves a bus round-trip (§4.2).

The session is the correlation key for nested lifecycles: the inner utterance carries the same session_id with session.response_mode populated (OVOS-CONVERSE-1 §5), which is what the converse plugin reads to route the reply to the waiting handler. No additional correlation field is defined by this specification.


7. Dispatch

When a plugin's match returns a non-None Match, the orchestrator dispatches the matched handler by emitting a Message on the topic:

<skill_id>:<intent_name>

where <skill_id> is Match.skill_id and <intent_name> is Match.intent_name. Both segments are bound by OVOS-MSG-1 §2.1.1 — neither may contain : — so the single : split is unambiguous.

7.0 Match.skill_id is the handler's identity

Match.skill_id is the skill_id of the component that will handle the dispatch. The orchestrator does not distinguish between a skill whose intents were registered via OVOS-INTENT-4 and a pipeline plugin that matched itself — both are reached by the same <skill_id>:<intent_name> dispatch topic, and the dispatched handler has the same obligations as any skill (OVOS-INTENT-4 §3.1).

A pipeline plugin that returns matches where skill_id equals its own pipeline_id is simply a component whose skill_id and pipeline_id are the same identifier — the two are one namespace (§3), so this is the ordinary case rather than a coincidence. It skips the OVOS-INTENT-4 registration step because it consumes no external intent registry — its match implementation decides directly whether to claim the utterance. There is no architectural difference; the dispatch path is identical.

7.1 Routing and payload

The dispatch Message's context (OVOS-MSG-1 §4):

  • session is propagated from the originating utterance;

  • source and destination follow the single-flip routing model (OVOS-MSG-1 §5.2) — the orchestrator derives the dispatch via reply, so destination is the original utterance emitter and source is the orchestrator;

  • context["skill_id"] stamping. The orchestrator MUST stamp context["skill_id"] = <skill_id> on every dispatch. MSG-1 derivation semantics carry this value forward into every Message the handler emits, satisfying OVOS-INTENT-4 §3.1 by construction.

  • context["pipeline_id"] stamping. The orchestrator MUST stamp context["pipeline_id"] on every dispatch with the pipeline_id of the plugin that produced the match (§3.1). When the match is self-addressed (skill_id == pipeline_id, §7.0), both context keys carry the same identifier.

  • session.active_handlers push. The orchestrator MUST push {skill_id: <skill_id>, activated_at: <orchestrator-stamped Unix timestamp in seconds>} onto the head of session.active_handlers, evicting any prior entry with the same skill_id. The push is applied after Match.updated_session is committed: a plugin that mutates active_handlers via updated_session (e.g. STOP-1's global stop wiping the list) sees the push applied on top, so the dispatched skill_id always lands at the head unless the push is suppressed. The push is suppressed for dispatches whose intent_name is marked non-activating in the §7.3 reserved-name registry — such a dispatch continues or terminates an already-active skill's participation rather than starting a fresh one. Suppression MUST be keyed on the Match's intent_name and its registry row — never on the producing pipeline_id; the orchestrator applies the polymorphism rule (§7.0) uniformly and does not otherwise distinguish skill from pipeline-plugin dispatches. Not every reserved name suppresses the push: the registry states it per row.

    Recency ordering (normative, defined once here). session.active_handlers is a recency record, and so is any list a specification defines with the same {skill_id, activated_at} shape (OVOS-CONVERSE-1 §2.1). Consumers that need "the most recently activated handler" — OVOS-STOP-1 §4.1 selecting a stop target, or any other — MUST resolve recency as follows:

    1. activated_at is authoritative: the entry with the highest activated_at is the most recently activated.
    2. When two or more entries share the same activated_at, the entry nearest the head of the list is the most recently activated, because the head is where the orchestrator pushes.

    Head position is therefore the tie-break, not the primary key, and the two rules never disagree with the push mechanics above. Consumers MUST NOT define their own recency order.

The dispatch Message's data:

{
  "lang": "en-US",
  "utterance": "play the beatles",
  "slots": { "query": "the beatles" }
}
Field Type Required Meaning
lang string yes The content language of the match, taken directly from Match.lang. A Match with no lang is malformed and never reaches dispatch (§4.1).
utterance string yes The candidate string that won the match.
slots object (string→string) yes The slot map (§4.3). MAY be empty.
typed_slots object no The typed-slot map (OVOS-INTENT-1 §5.6) as it stands on the entry Message after the typed-slots stage (§9.1, OVOS-TRANSFORM-1 §3.7), so a handler can look up a normalized value by the surface matching its slot value. Every type it carries has at least one entry: the orchestrator carries the map unchanged except that it drops any key naming an unregistered type and any key whose list is empty (OVOS-TRANSFORM-1 §3.7); it computes nothing itself, and omits the field entirely when no map is present after the stage.

skill_id and intent_name are not repeated in the payload — they are the topic's <skill_id>:<intent_name> prefix and suffix. A handler that needs them splits the topic on :.

7.2 Subscription discipline

Each handler subscribes to exactly its own <skill_id>:<intent_name> topic. A skill subscribes to topics under its own skill_id; a plugin that bundles its own handlers subscribes to topics under its own pipeline_id. Because each topic is unique to one handler, the bus delivers the dispatch only to the intended consumer.

A consumer that receives a dispatch on a topic it should not be listening to (a configuration bug) MUST NOT run the handler and SHOULD log the discrepancy. The orchestrator does not police subscriptions.

7.3 Reserved intent_names

Other normative specifications MAY reserve specific intent_name values for matches produced by a particular pipeline plugin role. A reserved intent_name is one that:

  • skills and pipelines MUST NOT register under OVOS-INTENT-4; a registration naming a reserved intent_name is malformed and every consumer (including the orchestrator's manifest) treats it under the OVOS-INTENT-4 §5.3/§6.3 malformed-payload rules — log at WARN, do not index;
  • a pipeline plugin MAY emit as the intent_name of a returned Match to signal "this match was produced by the role that reserves the name"; the dispatch then proceeds normally per §7, addressed to <skill_id>:<reserved_name>, and the handler subscribed to that topic does whatever the reserving specification defines.

A reservation is a namespace lease, not a dispatch modification. Dispatches on reserved intent_names fire §7.1 context stamping, §7.2 routing, and §8 handler-trio identically to ordinary dispatches. The one dispatch property a reservation may vary is the session.active_handlers push of §7.1, and it varies per name, not for reserved names as a class: the activation push column below states, for each reserved name, whether a dispatch bearing it pushes an entry. Suppression is keyed on the Match's reserved intent_name and this registry, never on the producing pipeline_id. The reserving specification gets exclusive use of the name across the deployment's skill set; it gets no other privilege.

Reserved intent_names:

Reserved intent_name Reserving spec Activation push Meaning of a Match bearing this name
converse OVOS-CONVERSE-1 §4 suppressed a converse plugin's claim that <skill_id> (an active handler) wants this utterance — the orchestrator dispatches <skill_id>:converse and the owner's converse handler runs
response OVOS-CONVERSE-1 §5 suppressed a converse plugin's signal that <skill_id> (the response-mode holder) is to receive the awaited utterance — the orchestrator dispatches <skill_id>:response and the owner's response handler runs
stop OVOS-STOP-1 §4 suppressed a stop plugin's claim that <skill_id> (an active handler) should cease activity — the orchestrator dispatches <skill_id>:stop and the owner's stop handler runs
fallback OVOS-FALLBACK-1 §6.3 pushes a fallback plugin's claim that <skill_id> (a registered fallback handler) is willing to handle the utterance — the orchestrator dispatches <skill_id>:fallback and the handler runs
common_query OVOS-COMMON-QUERY-1 §3 suppressed a common-query plugin's self-addressed match (Match.skill_id is the plugin's own pipeline_id) — the orchestrator dispatches <pipeline_id>:common_query and the plugin's bundled handler speaks the answer it selected during match

The converse, response and stop rows describe a skill that is already in active_handlers: converse and response continue an existing participation, and stop ends one. Pushing on those would restamp an activation the skill never re-earned.

fallback is different and therefore not suppressed. A fallback handler was not active before the dispatch — the dispatch is its activation, and it is the same kind of activation an ordinary intent dispatch performs. Suppressing the push would leave the handler unreachable by OVOS-STOP-1 (which selects its target from active_handlers) and ineligible for a converse follow-up, which is exactly wrong for a long-running or conversational fallback such as a language-model handler.

common_query is suppressed because the dispatch runs the plugin's own bundled handler, which speaks a single answer already selected during match and holds nothing afterwards.

This specification fixes only the registry mechanism (reservation listing); the per-name semantics are owned by the reserving specification. Other specifications MAY reserve further names by adding rows to this table in a revision of this specification.

A plain skill (§7.0) subscribes to a reserved-name dispatch topic via framework convention rather than OVOS-INTENT-4 registration — the reserved name is not registrable. The normal skill path (INTENT-4-registered intents) and the reserved-name path share the same <skill_id>:<intent_name> dispatch shape; no dispatch mechanics change.


8. Handler-lifecycle messages

The handler — whether a skill or a plugin-bundled handler — is a black box. Third-party handler code carries no obligation under this specification. The handler-lifecycle trio is emitted by the orchestrator that invokes the handler, wrapping the invocation: start before the call, then complete on normal return or error on exception. The handler itself does not emit anything.

The three broadcast notification topics are the handler-lifecycle trio:

Topic Meaning
ovos.intent.handler.start The orchestrator is about to invoke the handler.
ovos.intent.handler.complete The handler returned normally.
ovos.intent.handler.error The handler raised.

Each trio Message is produced via OVOS-MSG-1 §5.1 forward from the originating dispatch Message; source and destination are preserved unchanged. start carries the dispatch session; complete and error carry the session as synced at handler completion (OVOS-SESSION-2 §2.6). The trio is broadcast so any observer (loggers, transcript viewers, analytics, fallback chains) can subscribe.

8.1 Order and obligations

For each accepted dispatch, the orchestrator MUST emit:

  • ovos.intent.handler.start immediately before invoking the handler;
  • exactly one of ovos.intent.handler.complete (on normal return) or ovos.intent.handler.error (on exception) immediately after the invocation returns or raises.

A dispatch produces exactly one start and exactly one terminal event. The orchestrator owns the trio in full; no third-party code is required to participate.

8.2 Payload

Each lifecycle message's data:

{
  "skill_id": "music.skill",
  "intent_name": "play_music"
}

ovos.intent.handler.error adds an exception field:

{
  "skill_id": "music.skill",
  "intent_name": "play_music",
  "exception": "RuntimeError: Spotify is not configured"
}
Field Type Required Meaning
skill_id string yes The skill_id of the handler that was dispatched.
intent_name string yes The intent the handler was dispatched for.
exception string error only Human-readable description of the failure raised by the handler.

Implementations MAY include additional fields but consumers MUST NOT require them.

8.3 Handler timeout

The orchestrator MAY bound handler execution by a deployment-defined time. If the handler has not returned within the bound, the orchestrator MUST emit ovos.intent.handler.error with an exception field indicating timeout, then MUST proceed to emit ovos.utterance.handled (§9.5).

The orchestrator MUST NOT re-emit the dispatch Message for the same match. Re-dispatch is not defined by this specification.


9. Utterance-layer messages

This specification formalizes the following utterance-layer bus events. All travel in standard OVOS-MSG-1 envelopes; routing follows the single-flip model of OVOS-MSG-1 §5.2.

9.1 The utterance-layer entry point — ovos.utterance.handle

The orchestrator subscribes to ovos.utterance.handle, the utterance-layer entry-point topic produced by any component that wants to feed an utterance into the assistant — a listener, a chat bridge, a CLI, a test harness, a remote-peer client. Receiving on this topic kicks off the lifecycle of §6.

Payload shape:

{
  "utterances": ["turn off the lights"],
  "lang": "en-US"
}
Field Type Required Meaning
utterances array of strings yes One or more candidate utterance strings.
lang string no BCP-47 language tag of the utterance. Present only when the producer authoritatively knows the content language (e.g. a chat client emitting text it locally typed in de-DE, or an audio service emitting text from an STT decoder run in en-US). When absent, the content language is not authoritatively known; producers MUST NOT synthesize a value on the wire. On receipt, the orchestrator MUST resolve the language once from OVOS-SESSION-1 §3.2 evidence fields and pass the resolved tag to every plugin's match call (§4) — a single resolution point keeps all stages matching in the same language; plugins MAY refine but MUST NOT re-derive independently.

The typed-slots stage adds a typed_slots map to this payload when the deployment runs one, replacing any map already present (OVOS-TRANSFORM-1 §3.7, OVOS-INTENT-1 §5.6). It is not a producer field: a component emitting on this topic supplies utterances and, when it knows it, lang. A map that arrives from a producer regardless is subject to the same closed-set drop as the stage's own output, and the map that reaches dispatch is whichever one is present once the stage has run (§7.1).

ovos.utterance.handle is the only entry topic name this specification recognizes. A conformant orchestrator subscribes to this topic; a conformant producer emits to it.

9.1.1 The lifecycle identifier — context.utterance_id

On receiving the entry Message the orchestrator MUST stamp context.utterance_id with a value of its own choosing — an opaque string naming this one utterance lifecycle — exactly once, at entry, regardless of any value already present on the entry Message: a utterance_id supplied by whatever produced the entry Message is not the lifecycle identifier and the orchestrator's stamp replaces it. Everything derived from the lifecycle after entry — the transformer passes, the pipeline contest and its polls, the pongs, the dispatch, the terminal events of §9.5 — carries the value the orchestrator stamped, because OVOS-MSG-1 §5's derivations preserve context keys; no component ever copies it by hand. A component downstream of entry MUST NOT overwrite the utterance_id the orchestrator stamped: regeneration downstream would detach every already-derived Message from its lifecycle.

A component that opens a lifecycle without passing through the orchestrator — a plugin serving an out-of-band query — sits at lifecycle entry itself and stamps under the same rule.

A get_response answer is a new lifecycle, not a continuation: the answer utterance enters at §9.1 like any other, gets its own utterance_id, and is claimed early by the session's response-mode holder. Continuity between the question's lifecycle and the answer's is session state (response_mode), never the identifier — an identifier shared across turns would let a stale poll answer from one turn decide the next, which is the failure this field exists to prevent. Tooling that wants to group an exchange groups by session_id.

The value is opaque: consumers compare it for equality and do nothing else. It MUST be unique per lifecycle within the deployment (a UUID is RECOMMENDED; no format is normative). Two Messages carry the same utterance_id iff they belong to the same lifecycle — which is the entire correlation rule: a poll answer whose utterance_id differs from the poll's answers some other question. The poll protocols built on this one (OVOS-CONVERSE-1, OVOS-FALLBACK-1, OVOS-COMMON-QUERY-1) correlate by this field and define nothing of their own. OVOS-MSG-1 §5.4's no-central-correlation rule is untouched: no host assigns ids centrally, no host tracks them, and equality comparison by whoever cares is all there is.

9.2 ovos.intent.matched

Emitted by the orchestrator after a plugin's match returns non-None, before the dispatch (§7) goes out. Broadcast (no destination).

Payload:

{
  "skill_id": "music.skill",
  "intent_name": "play_music",
  "lang": "en-US",
  "utterance": "play the beatles",
  "slots": { "query": "the beatles" },
  "pipeline_id": "template-high"
}
Field Type Required Meaning
skill_id string yes The handler's skill_id.
intent_name string yes The matched intent name.
lang, utterance, slots as §7.1 — Same semantics as the dispatch payload.
pipeline_id string yes The pipeline_id of the plugin that produced the match.

ovos.intent.matched is a notification, not a dispatch. Consumers MUST NOT treat receipt as permission or instruction to run a handler — handler invocation happens via the dispatch topic (§7).

9.3 ovos.intent.unmatched

Emitted by the orchestrator when pipeline iteration completed with no plugin claiming the utterance. Broadcast.

{
  "utterances": ["turn off the lights"],
  "lang": "en-US"
}
Field Type Required Meaning
utterances array of strings no The candidate utterance list that no plugin matched, as it stood after the utterance-transformer chain. Included for observability; consumers MUST NOT re-submit it without explicit user intent.
lang string no The BCP-47 tag the match round ran in — the resolved tag the orchestrator passed to every match call (§4), whether it came from the entry-topic Message (§9.1) or from §3.2 evidence. Present whenever iteration ran; absent only when iteration never started (an empty utterance list per §6.2).

Both fields are optional. An observer that receives no fields still knows no plugin matched — the topic name alone is normative.

This message MUST be followed immediately by ovos.utterance.handled (§9.5).

This is the intent-layer failure signal. It is distinct from a handler-layer error (§8): ovos.intent.unmatched means "no plugin claimed"; ovos.intent.handler.error means "a handler ran and raised."

9.4 The dispatch topic

<skill_id>:<intent_name> — see §7.

9.5 ovos.utterance.handled

The universal end-marker for an utterance. Emitted by the orchestrator on every terminal path — cancellation, no-match, matched-and-handler-completed, matched-and-handler-errored, matched-and-handler-timed-out.

Broadcast. Payload MAY be empty.

A conformant orchestrator MUST emit exactly one ovos.utterance.handled per entry-topic Message (§9.1) that enters the lifecycle. Multiple emissions for one utterance are malformed; zero is malformed for a Message that entered the lifecycle. A Message dropped under OVOS-SESSION-1 §2.5 for a malformed session carrier never enters the lifecycle at all: it receives no end marker, and ovos.session.rejected (OVOS-SESSION-1 §2.5) is the only Message emitted for it.

9.6 ovos.utterance.speak — natural-language response

ovos.utterance.speak is the natural-language output exit point of the pipeline — the symmetric counterpart to the ovos.utterance.handle entry point (§9.1). Together they define the natural-language I/O boundary of the voice assistant: human speech (or text) arrives on the entry topic; the assistant's natural-language response departs on this topic.

A handler emits ovos.utterance.speak to deliver a natural-language response string for the assistant to convey to the user. What the deployment does with the Message downstream — TTS rendering, audio queueing, playback, chat display — is out of scope for this specification and is defined by the output-path companion specification. A deployment with no audio output (a text-only chat bridge, a test harness) receives the same ovos.utterance.speak Message as an audio-capable deployment.

Payload:

{
  "utterance": "It is currently 22 degrees and sunny.",
  "lang": "en-US"
}
Field Type Required Meaning
utterance string yes The natural-language response string.
lang string no BCP-47 tag of the response language. When absent, the output stage resolves language from the session per OVOS-SESSION-1 §3.2.
listen bool no When true, the handler expects a follow-up utterance after this response is delivered; the output stage re-opens the user input channel once delivery completes. Absent or false means no follow-up is expected. The output-side behaviour this triggers is defined by the output-path companion specification.

Derivation and session propagation. A handler MUST derive each ovos.utterance.speak emission from the dispatch Message (§7) it received, per MSG-1 §5 derivation semantics. This carries context.session and context.skill_id forward automatically — the output layer (dialog-transformer chain OVOS-TRANSFORM-1 §3.5, TTS, delivery) can read the session and attribute the response without additional wire fields. An ovos.utterance.speak Message that does not derive from a dispatch is non-conformant.

Multiplicity and ordering. A handler MAY emit zero or more ovos.utterance.speak Messages. Zero is permitted — a handler that acts silently (playing a sound, toggling a device, queuing media) is conformant. When a handler emits multiple, the order of emission is the intended delivery order; the output stage SHOULD preserve it.

Broadcast. ovos.utterance.speak carries no destination — it is broadcast. Any output component subscribed to the topic may consume it.

9.7 ovos.policy.denied — policy denial diagnostic

Emitted by the component that denies something on policy grounds — the orchestrator when it drops a candidate Match for a blacklisted_* reason (§5.2, §5.3, §5.4), or a layer-2 gate (OVOS-BRIDGE-1 §4.1) when it refuses a session field a participant supplied. Broadcast. Consumers MAY ignore it — no lifecycle event this specification requires depends on its receipt.

{
  "field": "blacklisted_intents",
  "value": "skill-lights.openvoiceos:turn_off"
}
Field Type Required Meaning
field string yes The policy field responsible for the denial (blacklisted_pipelines, blacklisted_skills, blacklisted_intents, or the name of a session.* field a gate refused).
value string yes The specific candidate denied — a pipeline_id, a skill_id, a <skill_id>:<intent_name> pair, or the refused field's value, matching what field names.

The session the denial applied to travels in context.session, as on every Message of this section: the diagnostic is derived from the Message that was denied and carries its session, never a session_id in data (OVOS-INTENT-4 §11.1).

This message is informative only. It does not delay, gate, or replace any other Message this specification requires; it exists so a deployer can observe a denial that would otherwise leave no trace on the bus. It is emitted for policy denials alone — a deployer's configuration overriding a plugin's verdict — and not for a plugin's own non-matches such as the §6.2 missing-slot case, which remain silent: a deployer can read policy from configuration, but only the bus can tell them the policy fired.


10. Per-pipeline introspection

Each pipeline plugin owns the set of intents it currently has loaded. To let consumers (UIs, developer tools, debug viewers, other plugins) discover that set at runtime, this specification defines a pull-query / scatter-response pattern keyed on pipeline_id.

Every pipeline plugin MUST answer the query topic below with the set of intent_name values it currently owns (§10.4). The topic is keyed on the plugin's pipeline_id (§3), so a plugin referenced from several session.pipeline entries answers once, for the actor — there is no per-entry query and no per-entry answer. A plugin with no bundled handlers and nothing loaded answers with an empty intents array rather than staying silent, because silence is how a consumer detects an unloaded plugin. What a plugin MAY choose is how much per-intent detail it exposes beyond the required fields of §10.2, not whether it answers. Observers and introspection tools rely on this index to enumerate every handler in the deployment; without it, plugin-owned handlers are invisible to deployment-wide tooling that walks OVOS-INTENT-4 only. This is not OVOS-INTENT-4 registration — it is a one-way declaration of "these are the intent_names I dispatch on."

10.1 Query and response topics

Topic Direction Carries
ovos.pipeline.<pipeline_id>.intents.list request empty payload (or filters, see §10.3)
ovos.pipeline.<pipeline_id>.intents.list.response reply the plugin's currently-loaded intent set

A consumer that wants the loaded intents of a specific pipeline MUST emit on the per-pipeline_id topic above. There is no aggregate query — a consumer that wants the intent set of every loaded plugin emits one query per pipeline_id it cares about and aggregates the responses itself.

The pipeline_id in the topic is the same identifier carried by session.pipeline (§5) and by context["pipeline_id"] on any observed dispatch (§3.1); a consumer that has already observed a pipeline_id from any of these sources can query it directly.

10.2 Response payload

The plugin MUST emit the response derived via reply (OVOS-MSG-1 §5.2), so that routing metadata is preserved and the response reaches the requester through any layer-2 transport. The response carries the currently-loaded intent set:

{
  "pipeline_id": "template-high",
  "intents": [
    {
      "intent_name": "play_music",
      "skill_id": "music.skill",
      "lang": "en-US"
    },
    {
      "intent_name": "stop_music",
      "skill_id": "music.skill",
      "lang": "en-US"
    }
  ]
}
Field Type Required Meaning
pipeline_id string yes The responding plugin's id.
intents array yes Currently-loaded intents (possibly empty).
intents[].intent_name string yes Intent identifier.
intents[].skill_id string yes The skill_id of the handler. For a self-matching plugin, equals its pipeline_id.
intents[].lang string yes The language the intent is registered for.

A plugin MAY include additional per-intent fields (engine metadata, confidence thresholds, sample templates) but consumers MUST NOT require them.

10.3 Filters

The request payload MAY carry filters:

{ "lang": "en-US", "skill_id": "music.skill" }

When a filter is present, the plugin SHOULD restrict its response to intents matching every filter field. Unknown filter keys are ignored (forward-compatible).

10.4 Pull-query is the source of truth

Pipeline plugins MAY broadcast load-time announcements (e.g. when a skill registers new intents the plugin recompiles), but consumers that need accurate state MUST query ovos.pipeline.<pipeline_id>.intents.list and MUST NOT assume that any prior broadcast reached them. The bus is asynchronous, has no delivery guarantees, and a consumer that started after a load event missed the announcement.

A plugin MUST respond to every query it observes for its own pipeline_id. A consumer that receives no response within a deployment-defined timeout MAY retry; persistent silence indicates the plugin is not loaded.

Under a split orchestrator (§2), a pipeline plugin is loaded into exactly one orchestrator process — typically the utterance-handling process that owns the match round of §6. That process answers the per-pipeline_id query for plugins it hosts. Sibling processes do not respond on its behalf. A query is broadcast; the consumer accepts the single response that arrives from the hosting process.


11. Conformance

A deployment SHOULD:

  • load at least one pipeline plugin that consumes OVOS-INTENT-4 registrations when skills emitting keyword or template intents are present; without such a plugin those intents never match.

An orchestrator MUST:

  • subscribe to the utterance-layer entry topic ovos.utterance.handle (§9.1);
  • run every received utterance through the lifecycle of §6 exactly once;
  • emit ovos.utterance.handled (§9.5) exactly once per utterance, regardless of which terminal path was taken;
  • resolve the utterance language once per utterance and pass the same resolved tag to every plugin's match call (§4, §9.1);
  • compose the effective pipeline (§5.5) after the metadata- transformer chain has run, iterate it in order (§6.2), and stop at the first Match that survives the §4.1 well-formedness, §5.3 / §5.4 denylist, and required_slots checks;
  • reject a malformed Match as a declination and continue iteration (§4.1);
  • enforce blacklisted_skills and blacklisted_intents against every candidate Match, regardless of whether the producing plugin filters (§5.3, §5.4);
  • verify every slot listed in the matched intent's required_slots is present, and treat a shortfall as a declination (§6.2);
  • carry data.typed_slots onto the dispatch Message when a map is present after the typed-slots stage, dropping keys naming unregistered types and changing nothing else (§7.1, OVOS-TRANSFORM-1 §3.7), and MUST NOT consult it in the §6.2 backstops;
  • ignore any Match returned by a match call that already timed out, and never dispatch it (§4.4);
  • skip unknown pipeline_ids without failing the utterance (§5);
  • emit ovos.intent.unmatched when no plugin claimed (§9.3);
  • emit ovos.intent.matched (§9.2) on every successful claim, before the dispatch;
  • dispatch on <match.skill_id>:<match.intent_name> per §7;
  • stamp context["skill_id"] and context["pipeline_id"] on every dispatch (§7.1);
  • push the dispatched skill_id onto the head of session.active_handlers, except where the §7.3 registry marks the intent_name non-activating (§7.1);
  • handle a plugin exception by logging and continuing to the next plugin (§6.2), not by failing the utterance;
  • emit the handler-lifecycle trio (§8) wrapping every handler invocation: start before the call, then exactly one of complete (on normal return) or error (on exception or timeout, §8.3) after;
  • remain able to accept and process new ovos.utterance.handle messages while a handler is running (§6.5);
  • keep servicing its bus subscriptions while a match call is in flight, so that a plugin whose match strategy needs a bus round-trip can complete it (§6.5).

A pipeline plugin MUST:

  • expose a match(utterances, lang, session) → Match | None operation (§4);
  • when claiming, return a well-formed Match carrying every required field of §4.1 — skill_id, intent_name, lang, slots and utterance — never a partial or speculative claim, with every slot value a string even where the intent declared a typed slot (§4.3, OVOS-INTENT-1 §5.6);
  • bear a pipeline_id distinct from any other loaded plugin's id (§3);
  • respond to every ovos.pipeline.<own_pipeline_id>.intents.list query with a §10.2 response payload describing its currently-loaded intent set (§10.4) — pull-query is the source of truth that consumers rely on.

A handler (skill or plugin-bundled)

Handlers carry no normative obligation under this specification. The orchestrator owns the handler-lifecycle trio (§8) and the dispatch envelope (§7). A handler is an opaque callable; the spec binds the orchestrator that invokes it, not the handler itself.


See also

  • Bus Message Specification (OVOS-MSG-1) — the envelope, the single-flip routing model, the shared topic-component identifier rule (§2.1.1), the session carrier that holds pipeline.
  • Session Specification (OVOS-SESSION-1) — the wire shape of session, the registry mechanism under which this specification claims the pipeline field, and the deployment-default fallback rule for omitted / empty session.pipeline.
  • Intent and Entity Registration Bus Contract (OVOS-INTENT-4) — the registration wire format plugins consume (when they choose to).
  • Intent Definition Specification (OVOS-INTENT-3) — the intent concept and the orchestrator role.