If you want every voice command to go to your own conversation agent — a local LLM, an MCP-backed assistant, whatever — instead of Home Assistant’s built-in intents, the obvious approach is a catch-all wildcard. On HA 2026.5+ that obvious approach returns an HTTP 500 with MissingListError, and the queries that do parse get silently hijacked by built-in intents. This is the broke → tried → fixed of getting it working.

Problem

The goal: route all voice through to a custom agent (in our case, a local Hermes 3 model behind an MQTT bridge). The symptom when you try it the obvious way — a conversation trigger with a bare wildcard:

# automations.yaml — the obvious catch-all
- alias: "Route voice query to my agent"
  triggers:
    - trigger: conversation
      command:
        - "{text}"
  actions:
    - action: mqtt.publish
      data:
        topic: my/agent/ask
        payload: '{"text": ""}'

Speak anything and the conversation API returns:

HTTP 500 Internal Server Error
hassil.errors.MissingListError: Missing slot list {text}

And the requests that don’t 500 get worse: queries you’d expect to fall through to your agent get answered by a built-in intent instead. “When is the next low tide” comes back as:

I'm sorry, no device is playing.

That’s MediaPlayerNext matching on “next.” Other built-ins join in: HassGetState grabs “when is {name}”, HassTurnOn grabs “turn on {name}”, HassFanSetSpeed grabs “[set] {name} [to] {speed}”. Your custom routing never gets a look in.

Diagnosis

Two separate things are happening, and it’s easy to conflate them.

1. MissingListError is how hassil 3.x resolves {slot}. Home Assistant’s sentence matcher is hassil. In the 3.x line (3.0.0 was a March 2025 rewrite; 3.5.0 shipped Dec 2025, and a 3.x is what current HA ships — we hit this on HA 2026.5), a {slot} reference inside a sentence template is resolved as a named list lookup — it expects a list called slot to exist under lists:. If no such list is registered, parsing raises MissingListError, which surfaces as an HTTP 500 on the /api/conversation/process endpoint. A bare {text} is not a wildcard — it’s a reference to a list named text that you never defined. (If you’re coming from an older setup where a bare trigger slot seemed to “just work,” that’s the change to be aware of; declare the wildcard explicitly now.)

2. The hijacking is intent ordering. Even once you stop 500-ing, HA’s built-in intents are in the match set. Several of them are broad enough to swallow general English — MediaPlayerNext matches “next [anything]”, HassGetState matches “when is …”, and so on. Whoever matches first wins, and the built-ins were matching first.

So the fix has to do two jobs at once: register a real wildcard so the slot resolves, and get our intent to match ahead of the built-ins.

What we tried (and why it failed)

Attempt 1 — automation conversation trigger with a bare wildcard

# automations.yaml
- alias: "Route voice query to my agent"
  triggers:
    - trigger: conversation
      command:
        - "{text}"          # <-- treated as list lookup, not wildcard
  actions:
    - action: mqtt.publish
      data:
        topic: my/agent/ask
        payload: '{"text": ""}'

Result:

hassil.errors.MissingListError: Missing slot list {text}
→ HTTP 500 on the conversation API

This is a structural dead-end, not a typo. The conversation trigger accepts sentence templates, but it gives you nowhere to declare a lists: block — there is no wildcard: true knob on an automation trigger. So any {slot} you put in a trigger command is forced to be a named-list lookup, and you have no way to register that list. You cannot build a true catch-all out of an automation trigger on hassil 3.x. (Renaming {text} to {slot} changes nothing — same error, different list name.)

Attempt 2 — narrow the greedy built-ins instead

If general queries are being eaten by MediaPlayerNext and friends, maybe just redefine those built-ins to be less greedy and let everything else fall through:

# custom_sentences/en/naturali.yaml
language: "en"
intents:
  MediaPlayerNext:
    data:
      - sentences:
          - "(skip|next) (song|track)"
          - "play next (song|track)"
  MediaPlayerPause:
    data:
      - sentences:
          - "(pause|stop) [the] (music|playback)"
  # ...and the rest of the media intents, narrowed

This did stop “when is the next low tide” from hitting MediaPlayerNext — narrowing the sentences works. But it’s whack-a-mole: every broad built-in (HassGetState, HassTurnOn, HassFanSetSpeed, …) is a separate intent you’d have to find and re-scope, and a future HA release can add or widen one and silently start swallowing your queries again. It also still depends on Attempt 1’s trigger to actually catch the fall-through — which 500s. Narrowing the built-ins treats the symptom; it doesn’t give you a catch-all.

The fix

Move the catch-all out of the automation trigger and into a custom sentence with a real wildcard list, then handle the intent with intent_script.

custom_sentences/en/naturali.yaml:

language: "en"
intents:
  NaturaliQuery:
    data:
      - sentences:
          - "{text}"
lists:
  text:
    wildcard: true

configuration.yaml:

intent_script:
  NaturaliQuery:
    speech:
      text: "One moment, Captain."
    action:
      - action: mqtt.publish
        data:
          topic: naturali/intents/ask
          payload: '{"text": ""}'

Two things make this work where the trigger couldn’t:

  • lists: text: wildcard: true registers text as a WildcardSlotList, so {text} matches arbitrary speech instead of resolving to a missing named list. No more MissingListError. Custom sentences are the only place you can declare this — which is exactly what the automation trigger lacked.
  • Custom sentences load before built-in HA intents (in our testing on HA 2026.5). So NaturaliQuery is matched first and intercepts everything — including the HassGetState / HassTurnOn / HassFanSetSpeed / MediaPlayerNext matches that were hijacking sailing queries. That ordering is the whole reason this fixes the hijacking without touching a single built-in.

intent_script (rather than an automation) handles the matched intent: it speaks the acknowledgement back through the normal voice pipeline and publishes the query text downstream. Note the slot is referenced as in `intent_script` — not; there’s no trigger object here.

Reload custom sentences (conversation.reload) for the sentence change; intent_script lives in configuration.yaml, so that part needs a full HA restart to take effect.

Why it matters / gotchas

The fix hinges on the thing that’s also the trap: custom sentences load before built-ins, so a {text} wildcard intent swallows the entire grammar. That’s the feature here — we want everything to go to the agent. But if you only want some sentences routed to your agent and the rest handled by HA natively, a bare {text} wildcard is a sledgehammer: it will intercept “turn on the lights” too, and your built-in device control stops working. For partial routing, scope the wildcard sentence (a required prefix word, a leading keyword) so it doesn’t match plain device commands.

Other things worth knowing:

  • MissingListError → HTTP 500 is generic. Any undeclared {slot} in a custom sentence or trigger produces it, not just wildcards. If you see it, look for a {slot} with no matching lists: entry.
  • intent_script over an automation action for the response. Handling the response in intent_script keeps the speech flowing back through the conversation pipeline. If your TTS satellite is the kind that deadlocks when you call its announce service while it’s mid-response (some assist-satellite setups do), routing the reply through intent_script.speech sidesteps that entirely — you never issue a competing announce call.
  • Don’t bother narrowing the built-ins. Once the wildcard custom sentence is in front, it matches first and the built-ins never get the query. The narrowing work from Attempt 2 becomes dead code.

Close

This came out of building an AI ops layer for an all-electric charter catamaran — local LLM, SignalK, MCP servers, voice on a Home Assistant front-end — where “when is the next low tide” needs to reach the navigation agent, not a media player. The MCP servers that sit behind this voice routing are open source: github.com/sailingnaturali.