Skip to content

Intent Service

Maturity: Mature ⬤⬤⬤⬤⬤

Long-lived, well-tested, and actively maintained. You can depend on it. Rated by repository health, not version. See also Intent Design for how a skill defines the keyword and template intents this service matches.

In a nutshell

The Intent Service is the part of OpenVoiceOS that figures out what you actually meant. Once the Speech Service has turned your words into text, this service reads that text and decides which skill should handle it, much like a receptionist hearing your request and directing you to the right desk. It tries a series of matchers in order and stops at the first one confident enough to respond. New to the terms? See the Glossary.

Module: ovos_core.intent_services.service.IntentService: ovos_core/intent_services/service.py

📐 Formal specification

The utterance lifecycle, the match(utterances, lang, session) → Match contract (the third argument is the utterance Message, from which a plugin reads the session via message.context["session"]), first-match-wins ordering, and the dispatch/handler-lifecycle events are specified by OVOS-PIPELINE-1: Utterance Lifecycle & Pipeline. What an intent is (keyword vs template) and the match-result shape come from OVOS-INTENT-3: Intent Definition. How skills declare intents and entities on the bus comes from OVOS-INTENT-4: Intent & Entity Registration. See also the spec index. IntentService is the reference orchestrator of this lifecycle. The spec topic names below are canonical.

IntentService is the component of ovos-core responsible for routing user utterances through the configured Intent Pipeline until a match is found.

In plain terms: this is the part that takes the words you said and figures out which skill should answer. It runs each matcher in your pipeline in order, such as stop, converse, padatious, and adapt, and stops at the first one confident enough to handle the request.


Technical Reference

Utterance Handling Flow

When an ovos.utterance.handle message (legacy: recognizer_loop:utterance) arrives on the bus, it triggers the lifecycle entry point of OVOS-PIPELINE-1 §9.1:

The orchestrator stamps its own utterance_id

At entry the orchestrator writes a fresh uuid into context.utterance_id, replacing whatever the entry message carried (OVOS-PIPELINE-1 §9.1.1). An id supplied by whatever produced the utterance is not the lifecycle identifier, so a bridge or satellite that sets one for its own tracing must not expect to see it again: everything downstream correlates on the orchestrator's value, which Message.reply and Message.forward propagate. Once stamped, no component overwrites it.

flowchart TD
    START(["ovos.utterance.handle<br/>(legacy: recognizer_loop:utterance) §9.1"])
    START --> UT["UtteranceTransformersService.transform()<br/>utterance-transformer chain, TRANSFORM-1 §3.2"]
    UT --> MT["MetadataTransformersService.transform()<br/>metadata-transformer chain, TRANSFORM-1 §3.3"]
    MT --> LANG["disambiguate_lang()<br/>pick the best language"]
    LANG --> SESS["_validate_session()<br/>get/create Session"]
    SESS --> MATCH{"for each pipeline plugin, in order<br/>match(utterances, lang, session) §4, §6.2"}
    MATCH -->|match found| MATCHED["ovos.intent.matched (§9.2) → dispatch → handler trio (§8)"]
    MATCH -->|no match| MATCH
    MATCH -->|no plugin matched| UNMATCHED["ovos.intent.unmatched (§9.3,<br/>legacy: complete_intent_failure)"]

Diagram: an incoming ovos.utterance.handle message flows through utterance and metadata transformers, language and session resolution, and the ordered pipeline-plugin match loop, ending in either a dispatched ovos.intent.matched or, if no plugin matches, ovos.intent.unmatched.

Reading top to bottom: an incoming utterance is first reshaped by the utterance- and metadata-transformer chains. Then the best language and a Session are resolved once, up front. Every pipeline plugin after that point sees the same already-prepared utterance, language, and session. The plugins themselves are then tried strictly in configured order. The first one to return a match wins and short-circuits the rest. If none of them do, the lifecycle ends in ovos.intent.unmatched instead of a dispatch.

Every lifecycle terminates with exactly one ovos.utterance.handled (§9.5), the universal end-marker, whether or not anything matched.

Language Disambiguation

The language for an utterance is chosen based on a priority list from message context keys:

  1. stt_lang: language used by STT to transcribe.

  2. request_lang: volunteered by the source (e.g. wake word).

  3. detected_lang: detected by a transformer plugin.

  4. Config default / message.data["lang"].

The chosen language is validated against valid_langs from config using closest_lang() (from ovos_spec_tools), which tolerates near-matches such as en vs en-us.

This ordering is the orchestrator's own consolidation

OVOS-SESSION-1 §3.2 treats these language signals as informative and deliberately does not mandate a fixed precedence. It warns that request_lang is a hint a consumer "MUST NOT treat as a guarantee" (§3.2.5), and for intent matching, suggests preferring stt_lang / detected_lang then lang. The fixed list above is how ovos-core's intent service consolidates them in practice. Another orchestrator may weigh them differently.

Multilingual Matching

When intents.multilingual_matching is enabled, the language fallback is per pipeline plugin, not a second whole-pipeline pass. For each plugin in priority order, the orchestrator first tries it in the primary language. If that plugin declines, it retries the same plugin in every other configured language, and only then advances to the next plugin. A consequence is that a lower-priority plugin's alternate-language match can win over a higher-priority plugin that was never reached. When the config is off, every plugin is tried in the primary language only.

Session Management

Each utterance is associated with a Session.

  • The per-session intent_context (session.intent_context) decays: entries carry an expires_at computed from the context.timeout config (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). This is what "expires," not the session itself. The "default" session is persisted in-process by the orchestrator, not destroyed. See Session Aware Skills.

  • Non-default sessions (e.g., from HiveMind clients) are updated but not reset.

  • Session state (active skills, pipeline, blacklists) is serialized into every reply message under context.session.

Intent Match Emission

When a pipeline plugin returns a match:

  1. IntentTransformersService.transform(match): the intent-transformer chain post-processes the match (OVOS-TRANSFORM-1 §3.4).

  2. Build the dispatch message (message.reply(match.match_type, …)) with match.match_type as the message type.

  3. Activate the skill in the session (sess.activate_skill(skill_id)) and emit {skill_id}.activate for the skill's callback.

  4. Emit ovos.intent.matched (§9.2), a notification that a plugin claimed the utterance.

  5. Wrap the dispatch in the handler-lifecycle trio. The orchestrator emits ovos.intent.handler.start, then exactly one of ovos.intent.handler.complete / ovos.intent.handler.error (§8). The skill's intent handler runs between them.

    The trio is orchestrator-owned, but the handler has to signal done

    The orchestrator emits start before the dispatch and exactly one of complete (normal return) or error (exception) after it, each forward-derived from the dispatch message so context and session are preserved. The payload is {skill_id, intent_name}, plus exception on the error leg. The dispatch is never re-emitted. A handler's own messages (a skill calling self.speak() emits ovos.utterance.speak) are not part of the trio's bookkeeping.

    What closes the trio, though, is the framework done-signal mycroft.skill.handler.complete / .error. Dispatch is a bus emit, not a function call, so there is no return for the orchestrator to observe. OVOSSkill emits the done-signal for you. A handler that is not an OVOSSkill — a plugin-bundled handler, a bridge, a satellite — must emit it itself. If it does not, the dispatch stays in-flight until the backstop timer fires (DEFAULT_HANDLER_TIMEOUT, five minutes; intents.handler_timeout) and every one of its turns ends as a timeout error.

Threading and Failure Model

Skills run in the same process as ovos-core. The SkillManager thread loads and supervises them, not a separate process per skill. Each skill talks to the bus either through ovos-core's single shared connection (websocket.shared_connection: true, the default), or, if set to false, through its own private connection. See messagebus Configuration.

Each skill's handlers (intents, converse, events) run synchronously inside create_wrapper(), on whichever thread delivers the message to that skill's bus subscription. There is no per-handler thread pool. This means that when websocket.shared_connection is false and two skills each own a private bus connection, they can handle messages concurrently. A single slow handler blocks only its own skill's subsequent messages, not other skills'.

The default is websocket.shared_connection: true, in which case every skill shares ovos-core's single bus connection, so a slow handler on that connection can delay message delivery to other skills as well. See messagebus Configuration.

create_wrapper() runs the handler inside a try/except/finally. An uncaught exception is caught, logged, and reported via the handler's .error message, and unless speak_errors=False, spoken back to the user as a generic "I ran into an error" style dialog. It never crashes ovos-core or the offending skill's process. A handler can also raise AbortEvent to end the current handler run early and skip the .error path, treating it as a normal (early) completion instead of a failure.

Intent Query API

External tools can query the pipeline without triggering a skill action:

intent.service.intent.get  {utterance: "...", lang: "..."}
  → intent.service.intent.reply  {intent: {...} | null, utterance: "..."}

Bus Events Handled

Event Handler
ovos.utterance.handle (legacy: recognizer_loop:utterance) handle_utterance
add_context handle_add_context
remove_context handle_remove_context
clear_context handle_clear_context
intent.service.intent.get handle_get_intent
intent.service.skills.deactivate _handle_deactivate
intent.service.pipelines.reload handle_reload_pipelines

The *_context events (add_context / remove_context / clear_context) are legacy-compat input topics: OVOS-CONTEXT-1 §5.0 defines no context-mutation topic — the session is the only context write path. Modern emitters write session.intent_context directly; these handlers survive only so pre-§5.0 skill processes whose set_context() wrapper emits the old messages keep working (a redundant idempotent write-through). See Session Aware Skills.

INTENT-4 registration topics

Skills broadcast their intent and entity registrations on the canonical OVOS-INTENT-4 topics: ovos.intent.register.keyword, ovos.intent.register.template, ovos.intent.deregister, ovos.intent.enable / .disable. They exist alongside the legacy register_intent / register_vocab events, so pipeline plugins can consume the spec topics while skills on the legacy events keep working. Registrations are broadcast, not addressed. Every interested plugin indexes them in parallel, and the orchestrator keeps a passive manifest it serves through ovos.intent.list and ovos.intent.describe.

Reserved intent_name values

OVOS-PIPELINE-1 §7.3 keeps a registry of intent_name values leased to a pipeline-plugin role: converse, response, stop, fallback and common_query. A skill or pipeline must not register a reserved name under INTENT-4. A skill subscribes to the reserved dispatch topic by framework convention instead. The spec reserves these names, but ovos-core's intent manifest does not reject a reserved-name registration. It only warns on registrations missing required fields, so this is a contract skills must honor, not one the orchestrator enforces today. A reservation is a namespace lease, not a dispatch change. Reserved-name dispatches fire context stamping, routing and the handler trio like any other, except that the session.active_handlers push is suppressed, since a reserved name continues or terminates an already-active skill's participation rather than starting a fresh one. (Converse, fallback, and common-query are suppressed by pipeline identity; stop instead sets IntentHandlerMatch.suppress_activation on the match — see Life of an Utterance.) See Converse for how reserved names interact with converse/context handling.


Read next: Skill Installer · Concepts Overview Related: Pipelines Overview · Intent Design · Formal Specifications · Speech Service