Routing all voice to a custom agent in Home Assistant 2026.5+: fixing MissingListError and HTTP 500 with a wildcard custom sentence
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: trueregisterstextas a WildcardSlotList, so{text}matches arbitrary speech instead of resolving to a missing named list. No moreMissingListError. 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
NaturaliQueryis matched first and intercepts everything — including theHassGetState/HassTurnOn/HassFanSetSpeed/MediaPlayerNextmatches 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 matchinglists:entry.intent_scriptover an automation action for the response. Handling the response inintent_scriptkeeps 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 throughintent_script.speechsidesteps 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.