<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://engineering.sailingnaturali.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://engineering.sailingnaturali.com/" rel="alternate" type="text/html" /><updated>2026-06-01T23:28:40+00:00</updated><id>https://engineering.sailingnaturali.com/feed.xml</id><title type="html">Sailing Naturali — Engineering</title><subtitle>Engineering notes from building the boat-agent / marine-AI-ops stack behind Sailing Naturali — Home Assistant, SignalK, MCP servers, local LLMs, and NMEA marine data. Non-obvious fixes, the dead-ends, and the terse solutions.</subtitle><author><name>Sailing Naturali</name></author><entry><title type="html">Routing all voice to a custom agent in Home Assistant 2026.5+: fixing MissingListError and HTTP 500 with a wildcard custom sentence</title><link href="https://engineering.sailingnaturali.com/ha-2026.5-hassil-3-wildcard-voice-routing-missinglisterror/" rel="alternate" type="text/html" title="Routing all voice to a custom agent in Home Assistant 2026.5+: fixing MissingListError and HTTP 500 with a wildcard custom sentence" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://engineering.sailingnaturali.com/ha-2026.5-hassil-3-wildcard-voice-routing-missinglisterror</id><content type="html" xml:base="https://engineering.sailingnaturali.com/ha-2026.5-hassil-3-wildcard-voice-routing-missinglisterror/"><![CDATA[<p>If you want <em>every</em> 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 <code class="language-plaintext highlighter-rouge">MissingListError</code>, and the queries that <em>do</em> parse get silently hijacked by built-in intents. This is the broke → tried → fixed of getting it working.</p>

<h2 id="problem">Problem</h2>

<p>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 <code class="language-plaintext highlighter-rouge">conversation</code> trigger with a bare wildcard:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># automations.yaml — the obvious catch-all</span>
<span class="pi">-</span> <span class="na">alias</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Route</span><span class="nv"> </span><span class="s">voice</span><span class="nv"> </span><span class="s">query</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">my</span><span class="nv"> </span><span class="s">agent"</span>
  <span class="na">triggers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">trigger</span><span class="pi">:</span> <span class="s">conversation</span>
      <span class="na">command</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">{text}"</span>
  <span class="na">actions</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">action</span><span class="pi">:</span> <span class="s">mqtt.publish</span>
      <span class="na">data</span><span class="pi">:</span>
        <span class="na">topic</span><span class="pi">:</span> <span class="s">my/agent/ask</span>
        <span class="na">payload</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{"text":</span><span class="nv"> </span><span class="s">""}'</span>
</code></pre></div></div>

<p>Speak anything and the conversation API returns:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>HTTP 500 Internal Server Error
hassil.errors.MissingListError: Missing slot list {text}
</code></pre></div></div>

<p>And the requests that <em>don’t</em> 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:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>I'm sorry, no device is playing.
</code></pre></div></div>

<p>That’s <code class="language-plaintext highlighter-rouge">MediaPlayerNext</code> matching on “next.” Other built-ins join in: <code class="language-plaintext highlighter-rouge">HassGetState</code> grabs “when is {name}”, <code class="language-plaintext highlighter-rouge">HassTurnOn</code> grabs “turn on {name}”, <code class="language-plaintext highlighter-rouge">HassFanSetSpeed</code> grabs “[set] {name} [to] {speed}”. Your custom routing never gets a look in.</p>

<h2 id="diagnosis">Diagnosis</h2>

<p>Two separate things are happening, and it’s easy to conflate them.</p>

<p><strong>1. <code class="language-plaintext highlighter-rouge">MissingListError</code> is how hassil 3.x resolves <code class="language-plaintext highlighter-rouge">{slot}</code>.</strong> Home Assistant’s sentence matcher is <a href="https://github.com/OHF-Voice/hassil">hassil</a>. 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 <code class="language-plaintext highlighter-rouge">{slot}</code> reference inside a sentence template is resolved as a <em>named list lookup</em> — it expects a list called <code class="language-plaintext highlighter-rouge">slot</code> to exist under <code class="language-plaintext highlighter-rouge">lists:</code>. If no such list is registered, parsing raises <code class="language-plaintext highlighter-rouge">MissingListError</code>, which surfaces as an HTTP 500 on the <code class="language-plaintext highlighter-rouge">/api/conversation/process</code> endpoint. A bare <code class="language-plaintext highlighter-rouge">{text}</code> is not a wildcard — it’s a reference to a list named <code class="language-plaintext highlighter-rouge">text</code> 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.)</p>

<p><strong>2. The hijacking is intent ordering.</strong> 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 — <code class="language-plaintext highlighter-rouge">MediaPlayerNext</code> matches “next [anything]”, <code class="language-plaintext highlighter-rouge">HassGetState</code> matches “when is …”, and so on. Whoever matches first wins, and the built-ins were matching first.</p>

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

<h2 id="what-we-tried-and-why-it-failed">What we tried (and why it failed)</h2>

<h3 id="attempt-1--automation-conversation-trigger-with-a-bare-wildcard">Attempt 1 — automation <code class="language-plaintext highlighter-rouge">conversation</code> trigger with a bare wildcard</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># automations.yaml</span>
<span class="pi">-</span> <span class="na">alias</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Route</span><span class="nv"> </span><span class="s">voice</span><span class="nv"> </span><span class="s">query</span><span class="nv"> </span><span class="s">to</span><span class="nv"> </span><span class="s">my</span><span class="nv"> </span><span class="s">agent"</span>
  <span class="na">triggers</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">trigger</span><span class="pi">:</span> <span class="s">conversation</span>
      <span class="na">command</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s2">"</span><span class="s">{text}"</span>          <span class="c1"># &lt;-- treated as list lookup, not wildcard</span>
  <span class="na">actions</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">action</span><span class="pi">:</span> <span class="s">mqtt.publish</span>
      <span class="na">data</span><span class="pi">:</span>
        <span class="na">topic</span><span class="pi">:</span> <span class="s">my/agent/ask</span>
        <span class="na">payload</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{"text":</span><span class="nv"> </span><span class="s">""}'</span>
</code></pre></div></div>

<p>Result:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>hassil.errors.MissingListError: Missing slot list {text}
→ HTTP 500 on the conversation API
</code></pre></div></div>

<p>This is a <em>structural</em> dead-end, not a typo. The <code class="language-plaintext highlighter-rouge">conversation</code> trigger accepts sentence templates, but it gives you nowhere to declare a <code class="language-plaintext highlighter-rouge">lists:</code> block — there is no <code class="language-plaintext highlighter-rouge">wildcard: true</code> knob on an automation trigger. So any <code class="language-plaintext highlighter-rouge">{slot}</code> 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 <code class="language-plaintext highlighter-rouge">{text}</code> to <code class="language-plaintext highlighter-rouge">{slot}</code> changes nothing — same error, different list name.)</p>

<h3 id="attempt-2--narrow-the-greedy-built-ins-instead">Attempt 2 — narrow the greedy built-ins instead</h3>

<p>If general queries are being eaten by <code class="language-plaintext highlighter-rouge">MediaPlayerNext</code> and friends, maybe just redefine those built-ins to be less greedy and let everything else fall through:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># custom_sentences/en/naturali.yaml</span>
<span class="na">language</span><span class="pi">:</span> <span class="s2">"</span><span class="s">en"</span>
<span class="na">intents</span><span class="pi">:</span>
  <span class="na">MediaPlayerNext</span><span class="pi">:</span>
    <span class="na">data</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">sentences</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">(skip|next)</span><span class="nv"> </span><span class="s">(song|track)"</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">play</span><span class="nv"> </span><span class="s">next</span><span class="nv"> </span><span class="s">(song|track)"</span>
  <span class="na">MediaPlayerPause</span><span class="pi">:</span>
    <span class="na">data</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">sentences</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">(pause|stop)</span><span class="nv"> </span><span class="s">[the]</span><span class="nv"> </span><span class="s">(music|playback)"</span>
  <span class="c1"># ...and the rest of the media intents, narrowed</span>
</code></pre></div></div>

<p>This <em>did</em> stop “when is the next low tide” from hitting <code class="language-plaintext highlighter-rouge">MediaPlayerNext</code> — narrowing the sentences works. But it’s whack-a-mole: every broad built-in (<code class="language-plaintext highlighter-rouge">HassGetState</code>, <code class="language-plaintext highlighter-rouge">HassTurnOn</code>, <code class="language-plaintext highlighter-rouge">HassFanSetSpeed</code>, …) 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.</p>

<h2 id="the-fix">The fix</h2>

<p>Move the catch-all out of the automation trigger and into a <strong>custom sentence</strong> with a real wildcard list, then handle the intent with <code class="language-plaintext highlighter-rouge">intent_script</code>.</p>

<p><code class="language-plaintext highlighter-rouge">custom_sentences/en/naturali.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">language</span><span class="pi">:</span> <span class="s2">"</span><span class="s">en"</span>
<span class="na">intents</span><span class="pi">:</span>
  <span class="na">NaturaliQuery</span><span class="pi">:</span>
    <span class="na">data</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">sentences</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s2">"</span><span class="s">{text}"</span>
<span class="na">lists</span><span class="pi">:</span>
  <span class="na">text</span><span class="pi">:</span>
    <span class="na">wildcard</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">configuration.yaml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">intent_script</span><span class="pi">:</span>
  <span class="na">NaturaliQuery</span><span class="pi">:</span>
    <span class="na">speech</span><span class="pi">:</span>
      <span class="na">text</span><span class="pi">:</span> <span class="s2">"</span><span class="s">One</span><span class="nv"> </span><span class="s">moment,</span><span class="nv"> </span><span class="s">Captain."</span>
    <span class="na">action</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">action</span><span class="pi">:</span> <span class="s">mqtt.publish</span>
        <span class="na">data</span><span class="pi">:</span>
          <span class="na">topic</span><span class="pi">:</span> <span class="s">naturali/intents/ask</span>
          <span class="na">payload</span><span class="pi">:</span> <span class="s1">'</span><span class="s">{"text":</span><span class="nv"> </span><span class="s">""}'</span>
</code></pre></div></div>

<p>Two things make this work where the trigger couldn’t:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">lists: text: wildcard: true</code></strong> registers <code class="language-plaintext highlighter-rouge">text</code> as a <a href="https://developers.home-assistant.io/docs/voice/intent-recognition/template-sentence-syntax/">WildcardSlotList</a>, so <code class="language-plaintext highlighter-rouge">{text}</code> matches arbitrary speech instead of resolving to a missing named list. No more <code class="language-plaintext highlighter-rouge">MissingListError</code>. Custom sentences are the only place you can declare this — which is exactly what the automation trigger lacked.</li>
  <li><strong>Custom sentences load <em>before</em> built-in HA intents</strong> (in our testing on HA 2026.5). So <code class="language-plaintext highlighter-rouge">NaturaliQuery</code> is matched first and intercepts everything — including the <code class="language-plaintext highlighter-rouge">HassGetState</code> / <code class="language-plaintext highlighter-rouge">HassTurnOn</code> / <code class="language-plaintext highlighter-rouge">HassFanSetSpeed</code> / <code class="language-plaintext highlighter-rouge">MediaPlayerNext</code> matches that were hijacking sailing queries. That ordering is the whole reason this fixes the hijacking without touching a single built-in.</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">intent_script</code> (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 <code class="language-plaintext highlighter-rouge">in `intent_script` — not</code>; there’s no trigger object here.</p>

<p>Reload custom sentences (<code class="language-plaintext highlighter-rouge">conversation.reload</code>) for the sentence change; <code class="language-plaintext highlighter-rouge">intent_script</code> lives in <code class="language-plaintext highlighter-rouge">configuration.yaml</code>, so that part needs a full HA restart to take effect.</p>

<h2 id="why-it-matters--gotchas">Why it matters / gotchas</h2>

<p>The fix hinges on the thing that’s also the trap: <strong>custom sentences load before built-ins, so a <code class="language-plaintext highlighter-rouge">{text}</code> wildcard intent swallows the entire grammar.</strong> That’s the feature here — we <em>want</em> everything to go to the agent. But if you only want <em>some</em> sentences routed to your agent and the rest handled by HA natively, a bare <code class="language-plaintext highlighter-rouge">{text}</code> 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.</p>

<p>Other things worth knowing:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">MissingListError</code> → HTTP 500 is generic.</strong> Any undeclared <code class="language-plaintext highlighter-rouge">{slot}</code> in a custom sentence or trigger produces it, not just wildcards. If you see it, look for a <code class="language-plaintext highlighter-rouge">{slot}</code> with no matching <code class="language-plaintext highlighter-rouge">lists:</code> entry.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">intent_script</code> over an automation action for the response.</strong> Handling the response in <code class="language-plaintext highlighter-rouge">intent_script</code> 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 <code class="language-plaintext highlighter-rouge">intent_script.speech</code> sidesteps that entirely — you never issue a competing announce call.</li>
  <li><strong>Don’t bother narrowing the built-ins.</strong> 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.</li>
</ul>

<h2 id="close">Close</h2>

<p>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: <a href="https://github.com/sailingnaturali">github.com/sailingnaturali</a>.</p>]]></content><author><name>Sailing Naturali</name></author><category term="homeassistant" /><category term="selfhosted" /><category term="voice-assistant" /><category term="ai" /><category term="hassil" /><category term="assist" /><summary type="html"><![CDATA[In Home Assistant 2026.5+ (hassil 3.x), a bare {slot} in an automation conversation trigger raises MissingListError and returns HTTP 500 on the conversation API. Built-in intents like HassGetState and HassTurnOn hijack queries like 'when is the next low tide'. Here's the dead-end and the working fix: a custom-sentence WildcardSlotList plus intent_script.]]></summary></entry></feed>