<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bryan Clark</title>
    <description>The latest articles on DEV Community by Bryan Clark (@clarkbw--).</description>
    <link>https://dev.to/clarkbw--</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3999099%2F8d584f7a-5880-42f7-b8ea-4a89d82ed3ac.jpg</url>
      <title>DEV Community: Bryan Clark</title>
      <link>https://dev.to/clarkbw--</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/clarkbw--"/>
    <language>en</language>
    <item>
      <title>Push SignalK alarms to your phone with a zero-dependency relay</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:25:37 +0000</pubDate>
      <link>https://dev.to/clarkbw--/push-signalk-alarms-to-your-phone-with-a-zero-dependency-relay-92p</link>
      <guid>https://dev.to/clarkbw--/push-signalk-alarms-to-your-phone-with-a-zero-dependency-relay-92p</guid>
      <description>&lt;p&gt;SignalK knows when something is wrong. It raises alarms into its &lt;code&gt;notifications.*&lt;/code&gt; tree with a clear severity ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nominal &amp;lt; normal &amp;lt; alert &amp;lt; warn &amp;lt; alarm &amp;lt; emergency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A low-battery condition shows up as a &lt;code&gt;warn&lt;/code&gt;; a man-overboard lands at &lt;code&gt;notifications.mob&lt;/code&gt; with state &lt;code&gt;emergency&lt;/code&gt;. The data is right there, structured and timestamped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"notifications"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mob"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"emergency"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"visual"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sound"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Man overboard"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01T19:30:00Z"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem: &lt;strong&gt;nothing pushes those alarms to your phone when you're not standing in front of the chartplotter.&lt;/strong&gt; If you're below, ashore, or asleep, the alarm fires into the void. We wanted one dependable thing — a SignalK notification turning into a push on a phone — and we wanted it on a safety path, which raises the bar.&lt;/p&gt;

&lt;p&gt;This is the broke → tried → fixed of getting &lt;strong&gt;SignalK push notifications to your phone&lt;/strong&gt;. The punchline: a one-way SignalK→ntfy relay needs &lt;em&gt;zero&lt;/em&gt; npm dependencies, so we built &lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ntfy-relay&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we evaluated (and why each fell short)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option 1: the classic Pushover relay — stale
&lt;/h3&gt;

&lt;p&gt;The most-referenced community answer to "SignalK notification to phone" is a relay plugin that forwards notifications to a push service. The canonical one is &lt;a href="https://www.npmjs.com/package/signalk-pushover-notification-relay" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-pushover-notification-relay&lt;/code&gt;&lt;/a&gt;, targeting Pushover.&lt;/p&gt;

&lt;p&gt;The architecture is right — subscribe to notifications, POST to a push service. But the receipts are bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm view signalk-pushover-notification-relay
&lt;span class="go"&gt;signalk-pushover-notification-relay@1.0.0 | ISC | deps: none
published over a year ago

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# repo: last pushed 2022 — roughly four years stale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When we checked, the repo hadn't moved since 2022 and pushover.net was unresponsive. The plugin is fine; the &lt;em&gt;sink&lt;/em&gt; is the problem. You do not want to hang a safety-relevant alert on a paid, closed service that's unresponsive when you test it, behind a plugin nobody's touched in four years.&lt;/p&gt;

&lt;p&gt;That sent us looking for a better sink.&lt;/p&gt;

&lt;h3&gt;
  
  
  The better sink: ntfy
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://ntfy.sh" rel="noopener noreferrer"&gt;ntfy&lt;/a&gt; is the right target. It's open-source, free, self-hostable, actively maintained, and has real iOS and Android apps. Most importantly, its publish API is &lt;em&gt;just an HTTP POST&lt;/em&gt; — title, priority, and tags all ride in headers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Title: Low battery"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Priority: 4"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Tags: rotating_light"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer tk_yourtoken"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"House bank below 30%"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://ntfy.sh/your-topic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority is &lt;code&gt;1&lt;/code&gt; (min) through &lt;code&gt;5&lt;/code&gt; (max). &lt;code&gt;Tags&lt;/code&gt; is a comma-separated list of emoji shortcodes that render as icons on the notification. That's the entire contract. No SDK, no handshake — a POST with headers. For a man-overboard you'd send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Title: MOB"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Priority: 5"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Tags: sos"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"Man overboard"&lt;/span&gt; https://ntfy.sh/your-topic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Self-hostable, free, maintained, and a one-line publish API. That's the sink. Now: who relays SignalK into it?&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: the existing ntfy plugin — an early POC with the wrong scope
&lt;/h3&gt;

&lt;p&gt;There's already a plugin: &lt;a href="https://www.npmjs.com/package/signalk-ntfy" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ntfy&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;Enand-lab/signalk-ntfy&lt;/code&gt;). Verify it yourself — here's what we found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm view signalk-ntfy
&lt;span class="go"&gt;signalk-ntfy@0.0.4 | Apache-2.0 | deps: 2
dependencies:
  node-fetch: ^2.7.0
  ws: ^8.16.0
published 2 months ago

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# GitHub: ~9 commits, single author, 0 stars / 0 forks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is an early proof-of-concept: &lt;code&gt;0.0.4&lt;/code&gt;, one author, single-digit commits, effectively no adoption. That alone is reason to be careful on a safety path — but the more interesting issue is &lt;em&gt;scope&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;signalk-ntfy&lt;/code&gt; is &lt;strong&gt;bidirectional&lt;/strong&gt;. Its headline feature is interactive ntfy buttons whose responses get published &lt;em&gt;back&lt;/em&gt; into SignalK. That's a neat idea, and it's exactly why it pulls in &lt;strong&gt;two runtime dependencies&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ws&lt;/code&gt; — a WebSocket client to listen for button-press responses coming back from ntfy.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;node-fetch&lt;/code&gt; — to POST to ntfy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a one-way &lt;em&gt;safety relay&lt;/em&gt;, the bidirectional half is dead weight, and both dependencies exist only to serve it. Worse, for our purposes the POC is &lt;em&gt;missing the things a relay actually needs&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;edge-triggering&lt;/strong&gt; — notify on a &lt;em&gt;state change&lt;/em&gt;, not repeatedly while a condition persists;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;severity filtering&lt;/strong&gt; — a configurable floor so &lt;code&gt;alert&lt;/code&gt;-level chatter doesn't page you;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;severity → ntfy priority/tags mapping&lt;/strong&gt; — so an &lt;code&gt;emergency&lt;/code&gt; arrives as priority 5 with an SOS icon;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;position enrichment&lt;/strong&gt; — where was the boat when this fired;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tests&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the choice was: adopt an immature &lt;code&gt;0.0.x&lt;/code&gt; and bolt the relay essentials onto a bidirectional architecture we don't want — or build a focused relay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 3: a generic SignalK→MQTT gateway — wrong shape
&lt;/h3&gt;

&lt;p&gt;We also considered routing notifications through a generic SignalK→MQTT bridge and pushing from there. Wrong shape for a phone push: it speaks a foreign topic scheme, carries no notion of ntfy priority or tags, and pushes the severity→presentation mapping problem somewhere else instead of solving it. A relay should own the SignalK-notification → phone-push semantics end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: a focused, zero-dependency relay
&lt;/h2&gt;

&lt;p&gt;Here's the key insight that made the whole thing easy: &lt;strong&gt;a one-way SignalK→ntfy relay needs no npm dependencies at all.&lt;/strong&gt; Both of the existing plugin's dependencies fall away once you drop the bidirectional feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;ws&lt;/code&gt;.&lt;/strong&gt; A SignalK &lt;em&gt;plugin&lt;/em&gt; runs in-process inside the SignalK server, so it reads notifications directly through the plugin API — no external WebSocket client. You subscribe to the &lt;code&gt;notifications.*&lt;/code&gt; subtree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signalk-ntfy-relay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SignalK ntfy relay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptionmanager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vessels.self&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;instant&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;[],&lt;/span&gt;                                   &lt;span class="c1"&gt;// unsubscribes array&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;plugin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ws&lt;/code&gt; dependency in the existing plugin exists only to hear button responses come &lt;em&gt;back&lt;/em&gt; from ntfy. A relay never listens, so it's gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;node-fetch&lt;/code&gt;.&lt;/strong&gt; Node ships an HTTPS client. The entire ntfy POST is the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;postToNtfy&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// e.g. https://ntfy.sh/your-topic&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Priority&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;              &lt;span class="c1"&gt;// 1..5&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tags&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;// emoji shortcodes&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ntfy post failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. &lt;code&gt;node-fetch&lt;/code&gt; was only ever a polyfill for &lt;code&gt;fetch&lt;/code&gt;/HTTP — and on a modern Node runtime you don't need it for a single POST.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edge-triggering: notify on change, not on repeat
&lt;/h3&gt;

&lt;p&gt;The relay essential the POC lacks. SignalK can re-emit a notification delta while a condition holds; you do &lt;em&gt;not&lt;/em&gt; want a buzz every few seconds for a battery that's still low. Keep an in-memory &lt;code&gt;Map&lt;/code&gt; of the last state seen per path and only fire on transitions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// path -&amp;gt; last notification state&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updates&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// nominal|normal|alert|warn|alarm|emergency&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// no change — skip&lt;/span&gt;
      &lt;span class="nx"&gt;lastState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;severityRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;severityRank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;minSeverity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;relay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One transition, one push. A condition that clears (back to &lt;code&gt;normal&lt;/code&gt;/&lt;code&gt;nominal&lt;/code&gt;) is itself a transition — so you can send a "cleared" notification too, instead of leaving a stale alarm on the phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Severity → priority + tags
&lt;/h3&gt;

&lt;p&gt;The mapping that turns a SignalK state into something ntfy renders meaningfully — loud icons for the loud states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;nominal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;emergency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;severityRank&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;RANK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// state -&amp;gt; { ntfy priority (1..5), tags (emoji shortcodes) }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NTFY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;emergency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;alarm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rotating_light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warning&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;information_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;cleared&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;white_check_mark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// back to normal/nominal&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;emergency&lt;/code&gt; (man-overboard) lands as priority 5 with an SOS icon; a clear comes through as a quiet priority-1 check mark.&lt;/p&gt;

&lt;h3&gt;
  
  
  Position enrichment
&lt;/h3&gt;

&lt;p&gt;When something fires, &lt;em&gt;where were we?&lt;/em&gt; If the vessel has a fix, append a position line to the push body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSelfPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation.position&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;N&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;S&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ew&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;E&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;W&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ns&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
         &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ew&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Self-hosted server + token
&lt;/h3&gt;

&lt;p&gt;ntfy is self-hostable, so the server URL and an optional bearer token are config, defaulting to the public instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"server"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://ntfy.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-topic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tk_optional_for_self_hosted_or_private_topics"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minSeverity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warn"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why zero-dependency is the point, not a flex
&lt;/h2&gt;

&lt;p&gt;It would be easy to read "zero dependencies" as a vanity metric. On a safety path it isn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller supply-chain and audit surface.&lt;/strong&gt; Every dependency is code you didn't write running on the path between "alarm fired" and "phone buzzed." For something safety-relevant, the right number of transitive packages to audit is as close to zero as you can get. With &lt;code&gt;node:https&lt;/code&gt; and the in-process plugin API, there's nothing to audit but the plugin itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing to vendor or break on upgrade.&lt;/strong&gt; &lt;code&gt;node-fetch&lt;/code&gt; v2 vs v3 (CommonJS vs ESM), &lt;code&gt;ws&lt;/code&gt; majors — these are exactly the upgrades that silently break a plugin you forgot you were running. No deps, no upgrade treadmill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Readable end to end.&lt;/strong&gt; You can sit down and read the whole relay in one pass and convince yourself it does what it says. That property is worth more on an alarm path than any feature.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The contrast is the whole argument: the existing plugin's two dependencies aren't waste — they earn their keep serving the &lt;strong&gt;bidirectional button&lt;/strong&gt; feature. A one-way relay simply doesn't have that feature, so it doesn't pay that cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did &lt;em&gt;not&lt;/em&gt; do, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;signalk-ntfy&lt;/code&gt; as-is.&lt;/strong&gt; An immature &lt;code&gt;0.0.x&lt;/code&gt; with no adoption, on a safety-relevant path, missing edge-triggering / severity filtering / tests. No.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute the changes upstream.&lt;/strong&gt; Tempting, but its bidirectional architecture and goals genuinely differ from a one-way relay. Bending a focused relay onto a button-response design — or pushing a from-scratch rewrite into someone else's POC — serves nobody. A separately-publishable, single-purpose relay is cleaner and more reusable for the next person searching "&lt;strong&gt;signalk ntfy&lt;/strong&gt;."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a generic SignalK→MQTT gateway.&lt;/strong&gt; Wrong shape (covered above): foreign topic scheme, no severity/priority semantics, just relocates the mapping problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tests
&lt;/h2&gt;

&lt;p&gt;A safety relay gets tests, and &lt;code&gt;node:test&lt;/code&gt; ships with Node — so even the test layer adds no dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;edge-triggering fires once per transition&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minSeverity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://ntfy.sh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// fire&lt;/span&gt;
  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// repeat — skip&lt;/span&gt;
  &lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alarm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;send&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// change — fire&lt;/span&gt;

  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepStrictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alarm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;emergency maps to priority 5 / sos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepStrictEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NTFY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emergency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This came out of building the AI and notification plumbing for an all-electric sailing charter — where "the battery alarm fired" needs to reach a phone, not just the chartplotter, and the path it travels has to be auditable end to end. &lt;code&gt;signalk-ntfy-relay&lt;/code&gt; is &lt;strong&gt;MIT-licensed and zero-dependency&lt;/strong&gt; — on npm as &lt;a href="https://www.npmjs.com/package/signalk-ntfy-relay" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ntfy-relay&lt;/code&gt;&lt;/a&gt; and on &lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Install it from the SignalK app store (search "ntfy") or with &lt;code&gt;npm i signalk-ntfy-relay&lt;/code&gt;. If you've been searching for &lt;strong&gt;SignalK push notifications to your phone&lt;/strong&gt; — a &lt;strong&gt;SignalK notification relay&lt;/strong&gt; that turns a &lt;strong&gt;SignalK alarm into a phone&lt;/strong&gt; push — that's what it's for.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>ntfy</category>
      <category>pushnotifications</category>
      <category>notifications</category>
    </item>
    <item>
      <title>SEO and GEO on TanStack Start: prerender to static</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 20:25:33 +0000</pubDate>
      <link>https://dev.to/clarkbw--/seo-and-geo-on-tanstack-start-prerender-to-static-4bi7</link>
      <guid>https://dev.to/clarkbw--/seo-and-geo-on-tanstack-start-prerender-to-static-4bi7</guid>
      <description>&lt;p&gt;We moved the Sailing Naturali apex site off Squarespace and onto a code-owned &lt;a href="https://tanstack.com/start" rel="noopener noreferrer"&gt;TanStack Start&lt;/a&gt; app on Vercel. The point wasn't to save the $28/month — it was to stop editing the site through a GUI and start editing it through a git repo: content as files, every change a PR, every PR a Vercel preview, merge to ship. The repo is public: &lt;a href="https://github.com/sailingnaturali/web" rel="noopener noreferrer"&gt;github.com/sailingnaturali/web&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the migration. It was discovering that &lt;strong&gt;SEO and GEO are the same problem wearing two hats&lt;/strong&gt;, that TanStack Start ships with the one default that breaks both, and that fixing it is a single config block. This is the broke → tried → fixed of getting search engines &lt;em&gt;and&lt;/em&gt; answer engines to actually see a TanStack Start site — plus the verification gotchas that made a &lt;em&gt;correct&lt;/em&gt; build look broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;The classic SEO checklist (title, meta description, canonical, Open Graph, structured data) and the newer GEO checklist (be legible to GPTBot, PerplexityBot, ClaudeBot, Google AI Overviews) read like two different jobs. They are not. They share one spine:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bot fetches a URL and has to understand the page from the bytes it gets back — without running your JavaScript.&lt;/strong&gt; Googlebot &lt;em&gt;can&lt;/em&gt; render JS, but defers it and penalizes you for the round trip. The answer-engine crawlers largely don't render at all. So whether you're chasing a blue link or a cited sentence in an AI answer, the win condition is identical: &lt;strong&gt;return clean, fully-rendered HTML with good structured data on the first request.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TanStack Start, by default, does the opposite. It client-renders. Build the site, open the production HTML, and the body is an empty shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- .output/public/index.html — client-rendered default --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/entry-client-xxxx.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what every bot sees: an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; and a script tag. Googlebot queues it for deferred rendering; GPTBot and PerplexityBot see nothing worth quoting. You can have perfect meta tags and it doesn't matter, because the content the page is &lt;em&gt;about&lt;/em&gt; never made it into the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;The fix is not "add more meta tags." It's "stop shipping an empty root div." Once the rendered text and the structured data are in the static HTML, both checklists collapse into one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classic SEO&lt;/strong&gt; wants &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;, OG/Twitter cards, and Schema.org JSON-LD — all in the first response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GEO&lt;/strong&gt; wants the same first-response HTML to be &lt;em&gt;content-complete&lt;/em&gt; (the prose is actually there) plus a machine-readable map of the site so answer engines know what you are and which pages matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are downstream of one decision: &lt;strong&gt;prerender to static at build time.&lt;/strong&gt; Everything else — JSON-LD, sitemap, &lt;code&gt;llms.txt&lt;/code&gt; — layers on top of prerendered HTML and is nearly free once the prerender works. So prerendering is the highest-leverage move, and it's where we started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attempt 1 — meta tags first, prerender "later"
&lt;/h3&gt;

&lt;p&gt;The tempting order is to nail the &lt;code&gt;head&lt;/code&gt; (title/description/canonical/OG) first because it's the visible SEO surface, and treat prerendering as a perf optimization for later. We wired up a &lt;code&gt;pageHead()&lt;/code&gt; helper and shipped it on a client-rendered build.&lt;/p&gt;

&lt;p&gt;Result: the meta tags were correct, and the page body was still empty to a bot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# build, then look at what a crawler actually receives&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm build
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"all-electric"&lt;/span&gt; .output/public/index.html
&lt;span class="c"&gt;# (no output — the page's actual content isn't in the HTML)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson: meta tags describe a page whose content isn't there. For Googlebot it's a deferred-render penalty; for a non-rendering answer-engine crawler it's invisible. Ordering matters — prerender is the prerequisite, not the polish.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2 — enable prerender, but verify with plain &lt;code&gt;grep&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;We turned on prerendering (config below), rebuilt, and checked the output. On macOS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"all-electric"&lt;/span&gt; .output/public/index.html
Binary file .output/public/index.html matches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and worse, a phrase grep came back empty:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"can't"&lt;/span&gt; .output/public/index.html
&lt;span class="c"&gt;# (no output)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looked like the prerender had failed — empty matches and a "binary file" warning. It hadn't. Two separate verification traps, both of which make a &lt;em&gt;correct&lt;/em&gt; build look broken (diagnosed in the gotchas below). We nearly reverted a working config because the check lied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3 — local install fails on a build-script gate
&lt;/h3&gt;

&lt;p&gt;With prerendering on, a fresh local install started failing where CI on Vercel didn't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; ERR_PNPM_IGNORED_BUILDS  Ignored build scripts: esbuild.
Run "pnpm approve-builds" to pick which dependencies should be allowed
to run scripts, or set them in the dependenciesMeta field of package.json.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reflex is to re-add the old &lt;code&gt;pnpm.onlyBuiltDependencies&lt;/code&gt; array to &lt;code&gt;package.json&lt;/code&gt; — and on a current pnpm that field is ignored there, so nothing changes. The approval setting has moved out of &lt;code&gt;package.json&lt;/code&gt; and into &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; (fix below). This one didn't bite on Vercel — only locally — which is exactly the kind of environment skew that eats an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Prerender to static (the whole ballgame)
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;vite.config.ts&lt;/code&gt;, enable prerendering on the TanStack Start plugin and add the &lt;code&gt;nitro&lt;/code&gt; plugin for the Vercel target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tanstackStart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-start/plugin/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;nitro&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nitro/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;tanstackStart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;prerender&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;autoSubfolderIndex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// /about -&amp;gt; /about/index.html&lt;/span&gt;
        &lt;span class="na"&gt;autoStaticPathsDiscovery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// find routes automatically&lt;/span&gt;
        &lt;span class="na"&gt;crawlLinks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;// follow &amp;lt;a&amp;gt; tags and prerender them too&lt;/span&gt;
        &lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="nf"&gt;nitro&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the build emits real HTML, content and all. Verify it (correctly — see gotchas):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;pnpm build
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"all-electric"&lt;/span&gt; .output/public/index.html
... an all-electric charter catamaran ...   &lt;span class="c"&gt;# rendered text, not an empty root div&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire premise of the post in one config block. Everything below is layering.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. JSON-LD via route &lt;code&gt;head.scripts&lt;/code&gt; — it really does prerender
&lt;/h3&gt;

&lt;p&gt;You do &lt;em&gt;not&lt;/em&gt; need to hand-render a &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; in your component body. TanStack Start's route &lt;code&gt;head.scripts&lt;/code&gt; emits inline JSON-LD straight into the prerendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/routes/index.tsx (abridged) — the home route carries the site-level JSON-LD&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pageHead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationJsonLd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;websiteJsonLd&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../lib/seo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createFileRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)({&lt;/span&gt;
  &lt;span class="na"&gt;head&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;pageHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;scripts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;organizationJsonLd&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/ld+json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;websiteJsonLd&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified in the static output — two inline JSON-LD blocks (Organization + WebSite) land in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of &lt;code&gt;.output/public/index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'application/ld+json'&lt;/span&gt; .output/public/index.html | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
       2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a future version regresses this, the fallback is a script tag rendered in the component body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;
  &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;organizationJsonLd&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. A single source of truth, and build-time SEO assets
&lt;/h3&gt;

&lt;p&gt;Two small modules keep this maintainable so adding a page doesn't mean editing five files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;src/lib/site.ts&lt;/code&gt; — &lt;code&gt;siteConfig&lt;/code&gt; plus a &lt;code&gt;pages&lt;/code&gt; registry. Adding a page is one row; the sitemap and &lt;code&gt;llms.txt&lt;/code&gt; follow from it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/seo.ts&lt;/code&gt; — &lt;code&gt;pageHead()&lt;/code&gt; (title/description/canonical/OG/Twitter) and &lt;code&gt;organizationJsonLd()&lt;/code&gt; / &lt;code&gt;websiteJsonLd()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/lib/seo-assets.ts&lt;/code&gt; — pure builder functions for &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt;, and &lt;code&gt;llms.txt&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The assets are generated at build time, before &lt;code&gt;vite build&lt;/code&gt;, by a small script run through &lt;code&gt;tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// package.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx scripts/generate-seo-assets.mjs &amp;amp;&amp;amp; vite build"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the builders are pure functions over the &lt;code&gt;pages&lt;/code&gt; registry, the sitemap and &lt;code&gt;llms.txt&lt;/code&gt; can't drift from the actual site — they're derived from the same data the router uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;llms.txt&lt;/code&gt; — the GEO-specific lever
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;llms.txt&lt;/code&gt; (&lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llmstxt.org&lt;/a&gt;, Jeremy Howard's spec) is a plain-Markdown map of the site for answer engines: a one-line description, then the core pages with short summaries. It's the GEO analogue of &lt;code&gt;robots.txt&lt;/code&gt;/&lt;code&gt;sitemap.xml&lt;/code&gt; — instead of telling crawlers where they &lt;em&gt;may&lt;/em&gt; go, it tells models what you &lt;em&gt;are&lt;/em&gt; and which pages matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Sailing Naturali&lt;/span&gt;
&lt;span class="gt"&gt;
&amp;gt; A tech exec is using AI leverage to build a premium all-electric sailing&lt;/span&gt;
&lt;span class="gt"&gt;&amp;gt; charter in the Pacific Northwest — the kind of business AI can't deliver.&lt;/span&gt;

&lt;span class="gu"&gt;## Pages&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Sailing Naturali — an all-electric charter, built with AI&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://sailingnaturali.com&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(That's the live &lt;a href="https://sailingnaturali.com/llms.txt" rel="noopener noreferrer"&gt;&lt;code&gt;/llms.txt&lt;/code&gt;&lt;/a&gt; today — one page, because the site launched as one page. As routes land in the &lt;code&gt;pages&lt;/code&gt; registry, the file grows with them for free.)&lt;/p&gt;

&lt;p&gt;Honest caveat: adoption is uneven and Google has publicly said &lt;code&gt;llms.txt&lt;/code&gt; is &lt;em&gt;not required&lt;/em&gt;. We ship it anyway because it's a build-time-generated byproduct of the &lt;code&gt;pages&lt;/code&gt; registry — near-zero cost, plausible upside, trivially removable. Treat it as future-proofing, not a silver bullet.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The pnpm build-script gate
&lt;/h3&gt;

&lt;p&gt;Move the build-script approval out of &lt;code&gt;package.json&lt;/code&gt; (where pnpm 11 silently ignores it — that's the deprecation warning you keep seeing) and into &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;. On pnpm 11 the mechanism is an &lt;code&gt;allowBuilds&lt;/code&gt; map of booleans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pnpm-workspace.yaml&lt;/span&gt;
&lt;span class="na"&gt;allowBuilds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;esbuild&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;lightningcss&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this, &lt;code&gt;pnpm install&lt;/code&gt; runs the postinstall (esbuild fetches its platform binary) and &lt;code&gt;pnpm build&lt;/code&gt; works with no &lt;code&gt;--config.verify-deps-before-run=false&lt;/code&gt; escape hatch. (On older pnpm 10 the equivalent was an &lt;code&gt;onlyBuiltDependencies&lt;/code&gt; &lt;em&gt;array&lt;/em&gt; in the same file — check your pnpm major; the durable point is &lt;em&gt;the setting lives in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;, not &lt;code&gt;package.json&lt;/code&gt;&lt;/em&gt;.)&lt;/p&gt;

&lt;h3&gt;
  
  
  6. The cutover — keep your TLS cert and your canonical
&lt;/h3&gt;

&lt;p&gt;DNS lives on Cloudflare. The trap is the orange cloud: if Cloudflare proxies the records, Vercel can't complete the ACME challenge to provision its own TLS cert. Set the relevant records to &lt;strong&gt;DNS-only (grey cloud)&lt;/strong&gt; so Vercel owns TLS end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;# Cloudflare DNS — all DNS-only (grey cloud)
&lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;      @      &lt;span class="m"&gt;76&lt;/span&gt;.&lt;span class="m"&gt;76&lt;/span&gt;.&lt;span class="m"&gt;21&lt;/span&gt;.&lt;span class="m"&gt;21&lt;/span&gt;            ; &lt;span class="n"&gt;apex&lt;/span&gt; -&amp;gt; &lt;span class="n"&gt;Vercel&lt;/span&gt; (&lt;span class="n"&gt;use&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;IP&lt;/span&gt; &lt;span class="n"&gt;Vercel&lt;/span&gt; &lt;span class="n"&gt;shows&lt;/span&gt; &lt;span class="n"&gt;you&lt;/span&gt;)
&lt;span class="n"&gt;CNAME&lt;/span&gt;  &lt;span class="n"&gt;www&lt;/span&gt;    &lt;span class="n"&gt;cname&lt;/span&gt;.&lt;span class="n"&gt;vercel&lt;/span&gt;-&lt;span class="n"&gt;dns&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;   ; &lt;span class="n"&gt;www&lt;/span&gt; -&amp;gt; &lt;span class="m"&gt;307&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;apex&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;76.76.21.21&lt;/code&gt; is Vercel's standard apex IP, but it assigns from an Anycast pool — use whatever value the Vercel domain dashboard hands you rather than copying this one.)&lt;/p&gt;

&lt;p&gt;Two things that make the cutover boring instead of scary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Canonical points at the apex from day one.&lt;/strong&gt; &lt;code&gt;og:url&lt;/code&gt; and &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; are built from &lt;code&gt;siteConfig.url&lt;/code&gt; (the apex), not the request host — so even on the &lt;code&gt;*.vercel.app&lt;/code&gt; preview, search sees the apex as canonical. The moment DNS flips, the right canonical is already in the HTML; no duplicate-content window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email and verification survive the host swap.&lt;/strong&gt; &lt;code&gt;MX&lt;/code&gt;, &lt;code&gt;SPF&lt;/code&gt;, &lt;code&gt;DKIM&lt;/code&gt;, and the DNS-based &lt;code&gt;google-site-verification&lt;/code&gt; TXT record are independent of where the website is hosted. Leave them in place and they ride through the cutover untouched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

&lt;p&gt;The two verification traps from Attempt 2 are worth their own section, because they make a &lt;em&gt;correct&lt;/em&gt; build look like a failure and will send you reverting good code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha #1 — macOS/BSD &lt;code&gt;grep&lt;/code&gt; reports a correct build as "binary."&lt;/strong&gt; Nitro emits &lt;code&gt;index.html&lt;/code&gt; as a single long UTF-8 line packed with em-dashes and inline scripts. BSD &lt;code&gt;grep&lt;/code&gt; (the macOS default) sniffs that as binary and prints &lt;code&gt;Binary file ... matches&lt;/code&gt; instead of the line — or, with some flags, silently nothing. Force text mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"all-electric"&lt;/span&gt; .output/public/index.html   &lt;span class="c"&gt;# -a = treat as text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha #2 — React HTML-escapes apostrophes, so your grep misses real content.&lt;/strong&gt; React renders &lt;code&gt;can't&lt;/code&gt; as &lt;code&gt;can&amp;amp;#x27;t&lt;/code&gt; in the static HTML. A naive &lt;code&gt;grep "can't"&lt;/code&gt; (or &lt;code&gt;grep "can.t"&lt;/code&gt;) finds nothing and you conclude the prerender dropped the content. It didn't — grep an apostrophe-free phrase:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"all-electric charter"&lt;/span&gt; .output/public/index.html   &lt;span class="c"&gt;# no apostrophe, matches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of these bite hardest precisely when the build is &lt;em&gt;correct&lt;/em&gt;, which is the worst time to get a false negative.&lt;/p&gt;

&lt;p&gt;The deeper lesson is the spine itself: &lt;strong&gt;don't model SEO and GEO as two backlogs.&lt;/strong&gt; They're one — get clean, content-complete, well-structured HTML into the first response — and on TanStack Start that reduces to flipping prerendering on and then layering cheap, build-time-generated assets on top. The framework gives you the hard parts (typed &lt;code&gt;head&lt;/code&gt;, JSON-LD in &lt;code&gt;head.scripts&lt;/code&gt;, Nitro prerendering, link crawling); you supply a single source of truth so the derived assets can't drift. Worth reading alongside this: the official &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/seo" rel="noopener noreferrer"&gt;TanStack Start SEO&lt;/a&gt; and &lt;a href="https://tanstack.com/start/latest/docs/framework/react/guide/llmo" rel="noopener noreferrer"&gt;LLMO&lt;/a&gt; guides, which land on the same conclusion from the framework side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This is the apex marketing site for an all-electric charter catamaran we're building in the open — but the site itself is just a TanStack Start app, and the SEO/GEO module is reusable on any TanStack Start project. It's all in the public repo: &lt;a href="https://github.com/sailingnaturali/web" rel="noopener noreferrer"&gt;github.com/sailingnaturali/web&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>tanstackstart</category>
      <category>seo</category>
      <category>geo</category>
      <category>prerendering</category>
    </item>
    <item>
      <title>Fix LLM formatting in the tool layer, not the prompt</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:57:58 +0000</pubDate>
      <link>https://dev.to/clarkbw--/fix-llm-formatting-in-the-tool-layer-not-the-prompt-4p9</link>
      <guid>https://dev.to/clarkbw--/fix-llm-formatting-in-the-tool-layer-not-the-prompt-4p9</guid>
      <description>&lt;p&gt;If you point an LLM agent at an MCP server and then route its replies through text-to-speech, you will eventually hear it say something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Your position is forty-eight point seven six degrees north, one two three point zero four degrees west, and state of charge is zero point six eight."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a raw latitude/longitude pair and a 0–1 fraction being read aloud verbatim. The instinct is to fix it in the prompt — "always speak the battery as a percentage," "format position as degrees north and west." That instinct is a trap. This is the broke → tried → fixed of getting clean spoken output, and the punchline is: &lt;strong&gt;format in the tool response, not in the prompt.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a design post, not a single-bug writeup, but it maps to the same arc — a wrong behaviour, a dead-end that looks right, and a fix that actually holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;The boat agents here run on a local LLM with a Home Assistant voice front-end, backed by a set of MCP servers. The agent calls a tool, gets JSON back, composes a reply, and TTS speaks it. The trouble starts with what's in that JSON.&lt;/p&gt;

&lt;p&gt;SignalK stores everything in SI units, so a wind-speed read comes back as bare metres-per-second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"environment.wind.speedApparent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-18T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A battery state-of-charge comes back as a 0–1 fraction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"capacity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"stateOfCharge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.68&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A position comes back as a coordinate dict:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;48.7601&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-123.0410&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A timestamp comes back as ISO 8601 UTC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01T19:30:00Z"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Feed any of those to an LLM and tell it to talk, and you get raw values spoken aloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Eight point five."                                # the raw m/s, not knots
"State of charge zero point six eight."            # wrong — should be 68%
"Forty-eight point seven six zero one degrees      # unspeakable
 north, negative one two three point zero four
 one zero degrees..."
"Twenty twenty-six dash zero six dash zero one     # reads the ISO string
 tee nineteen thirty zero zero zed."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model is not malfunctioning. It is doing exactly what an LLM does: it sees a number it recognizes as a coordinate or a fraction, and it renders it however it feels like this turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;Here is the part that took a couple of cycles to internalize: &lt;strong&gt;the model reformats raw data it can see, regardless of your instructions, because it "knows" what that data is.&lt;/strong&gt; It has seen a billion latitudes. It knows &lt;code&gt;0.68&lt;/code&gt; near a key called &lt;code&gt;stateOfCharge&lt;/code&gt; is a fraction. It knows an ISO timestamp. So it will "helpfully" expand, convert, or mangle the raw field — and which way it mangles depends on the model, the temperature, and the phase of the moon.&lt;/p&gt;

&lt;p&gt;That means a prompt rule isn't a fix, it's a suggestion the model is free to ignore the moment the raw value is still in front of it. You can't reliably instruct the model &lt;em&gt;not&lt;/em&gt; to reformat data it can plainly read. The only reliable lever is the data itself: &lt;strong&gt;don't put the raw value where the model will narrate it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The dead-end: fix it in the prompt
&lt;/h3&gt;

&lt;p&gt;The obvious first move is to add formatting rules to the agent's system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# system prompt — formatting rules (the dead-end)
When you report battery state of charge, always say it as a
percentage (e.g. "68 percent"), never as a decimal.
When you report position, say it as degrees north and degrees
west, rounded to two decimals (e.g. "48.76 north, 123.04 west").
When you report a time, say the local time, never the UTC
timestamp.
Speak wind speed in knots.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the model and temperature you tested it on, this &lt;em&gt;mostly&lt;/em&gt; works. Ship it, and within a day TTS says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"State of charge is zero point six eight."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…because on this turn the model decided the decimal was fine. Tighten the rule, add an example, and now it works again — until you swap the local model for a different size, or the cloud model ships a new version, and the behaviour drifts back. Every fix is per-model and per-temperature.&lt;/p&gt;

&lt;p&gt;It also doesn't compose. Each new field is a new rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# ...and it keeps growing
Depth is in meters; say it in feet if under 10 meters...
Heading is degrees true; say "degrees" not "degrees true"...
Distance to waypoint is in meters; say nautical miles if over 1000...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prompt becomes a pile of unit-conversion rules, the model still leaks one of them on any given turn, and you're playing whack-a-mole against a non-deterministic narrator. The root cause — the raw value is right there in the tool result — is untouched. This is a structural dead-end, not a tuning problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move the formatting into the &lt;strong&gt;tool response&lt;/strong&gt;. Three techniques, in order of preference. All three are from our public MCP servers, so the real tool and field names below are fine to copy.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Pre-format a &lt;code&gt;display&lt;/code&gt; field
&lt;/h3&gt;

&lt;p&gt;Give the model a string that is already correct to read aloud, and let the raw value ride alongside for any code that needs to compute on it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;read_sensor&lt;/code&gt; (signalk-mcp) — the SI value rides alongside a pre-converted &lt;code&gt;display&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"environment.wind.speedApparent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"16.5 knots"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"knots"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-18T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;battery_state&lt;/code&gt; (signalk-mcp) — &lt;code&gt;display&lt;/code&gt; is a full spoken summary, with the units spelled out so TTS reads them naturally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"house"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"soc_fraction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.68&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"voltage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;12.84&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"current"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-8.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"68 percent, 12.8 volts, 8.2 amps discharging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-14T18:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mark_moment&lt;/code&gt; (logbook-mcp):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"entry_display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Entry 7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Beautiful sunset off Discovery Island"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01T19:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"position"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-123.04&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;48.42&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"position_display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"48.4 North, 123.0 West"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model reaches for the obvious field to speak, and the obvious field is now the right one. This is the highest-leverage move and should be the default for anything that has a "natural" spoken form.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Remove the raw field entirely
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;display&lt;/code&gt; field still leaves the raw value sitting in the JSON, and a model can still decide to narrate &lt;code&gt;soc_fraction&lt;/code&gt; instead of &lt;code&gt;display&lt;/code&gt;. When there is no good reason to return the raw value at all, &lt;strong&gt;don't.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_local_time&lt;/code&gt; (signalk-mcp) returns no &lt;code&gt;utc&lt;/code&gt; field — only the already-local, already-formatted time and the timezone it resolved to. There is no UTC string or ISO timestamp for the model to read aloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iana_timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"America/Vancouver"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"19:30"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;display&lt;/code&gt; is &lt;code&gt;HH:MM&lt;/code&gt; in 24-hour local time; the tool resolves the timezone from the vessel's GPS position and falls back to &lt;code&gt;"UTC"&lt;/code&gt; with no fix.)&lt;/p&gt;

&lt;p&gt;Position is the instructive exception. The same principle &lt;em&gt;suggests&lt;/em&gt; dropping the raw lat/lon — but here it collides with a real need: agents calling &lt;code&gt;read_sensor("navigation.position")&lt;/code&gt; need actual coordinates for programmatic use (computing a distance, feeding the next tool), not just a string to speak. So &lt;code&gt;read_sensor&lt;/code&gt; deliberately keeps the raw dict in &lt;code&gt;value&lt;/code&gt; and leans on technique 1 (a good &lt;code&gt;display&lt;/code&gt;) for the spoken form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navigation.position"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"latitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;48.76&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"longitude"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-123.05&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"48.7600 North, 123.0500 West"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"°"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-18T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the trade-off technique 2 forces you to make explicit: &lt;strong&gt;remove the raw field when nothing downstream needs it; keep it — and accept you're back to relying on technique 1 — when something does.&lt;/strong&gt; &lt;code&gt;get_local_time&lt;/code&gt; has no consumer for a UTC string, so it drops it outright and unspeakable output becomes impossible. Position has real consumers for the coordinates, so it keeps them and accepts the residual risk that a model &lt;em&gt;could&lt;/em&gt; expand the dict into "negative one two three point zero five degrees." Knowing which case you're in is the real decision — not reflexively stripping every raw field.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Name keys so they don't leak
&lt;/h3&gt;

&lt;p&gt;Key names leak into speech too — a model will sometimes narrate the field name, and a key that reads like a sentence fragment ("state of charge…", "timezone…") invites it. Name any key whose value isn't meant to be spoken so it reads as an opaque identifier, not prose. The two raw fields in these servers are named exactly this way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;soc_fraction    # not "state_of_charge": "soc fraction" won't get read as prose,
                # and it signals "this is a 0–1 number, not the thing to speak"
iana_timezone   # not "timezone": "America/Vancouver" is a machine value, and the
                # name says so — the spoken value lives in `display`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naming also encodes intent for the next person reading the schema: &lt;code&gt;soc_fraction&lt;/code&gt; says &lt;em&gt;this is a 0–1 number, the percentage is in &lt;code&gt;display&lt;/code&gt;.&lt;/em&gt; The spoken form always lives in a &lt;code&gt;display&lt;/code&gt;/&lt;code&gt;*_display&lt;/code&gt; field; the raw fields carry names that read as machine values. If a key's name would be spoken naturally, that's the smell — rename it or drop it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters / the one place a prompt rule is fine
&lt;/h2&gt;

&lt;p&gt;The principle generalizes past voice and past marine data: &lt;strong&gt;anywhere an LLM tool result feeds generated output, formatting belongs in the tool response, because that's the only layer that's deterministic.&lt;/strong&gt; The prompt is advisory; the tool result is data. Put the contract in the data.&lt;/p&gt;

&lt;p&gt;There is exactly one place where a prompt instruction is the right tool — and it's worth calling out so you don't over-rotate into "never touch the prompt":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the agent composes a confirmation sentence and reformats a value it already holds in context.&lt;/strong&gt; Say &lt;code&gt;mark_moment&lt;/code&gt; already gives back &lt;code&gt;position_display: "48.4 North, 123.0 West"&lt;/code&gt;, but the agent is writing a full confirmation like &lt;em&gt;"Marked. We're at forty-eight point four north, one twenty-three west."&lt;/em&gt; That is sentence structure, not a unit conversion — the model is arranging a value it legitimately has, into prose. A single, one-line format instruction tied to that specific tool response is fine there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# acceptable prompt rule — sentence structure, not unit conversion
After mark_moment, confirm using position_display verbatim,
phrased as "&amp;lt;position_display&amp;gt;".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line to hold: a prompt rule that says &lt;em&gt;how to arrange values into a sentence&lt;/em&gt; is fine. A prompt rule that says &lt;em&gt;convert this unit / expand this fraction / reformat this timestamp&lt;/em&gt; is the dead-end — that always belongs in the tool response. If you find yourself writing a conversion rule in the prompt, that's the signal to go fix the tool instead.&lt;/p&gt;

&lt;p&gt;A couple of nearby traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;display&lt;/code&gt; field alone is not airtight.&lt;/strong&gt; As long as the raw field is still in the payload (technique 1), a model &lt;em&gt;can&lt;/em&gt; still read it. Remove the raw field (technique 2) whenever nothing downstream needs it — that's the only way to make unspeakable output impossible rather than merely discouraged. When something genuinely needs the raw value (position's coordinates), you're knowingly back to relying on &lt;code&gt;display&lt;/code&gt;; that's a deliberate trade-off, not an oversight.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on more than one model.&lt;/strong&gt; The whole reason prompt rules fail is cross-model drift. Your tool-layer fix should be verified the same way — swap the local model, bump the cloud model version, confirm the JSON still speaks correctly. If it's in the tool response, it will; that's the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This came out of building an AI ops layer for an all-electric charter catamaran — a local LLM, SignalK, and a stack of MCP servers behind a Home Assistant voice front-end, where "what's our position" has to come back as something a human can actually hear. The servers the examples above are pulled from are open source: &lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt; (&lt;code&gt;signalk-mcp&lt;/code&gt;, &lt;code&gt;logbook-mcp&lt;/code&gt;).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>voiceassistant</category>
      <category>llm</category>
    </item>
    <item>
      <title>launchd's minimal PATH breaks MCP servers: uv command not found</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:57:54 +0000</pubDate>
      <link>https://dev.to/clarkbw--/launchds-minimal-path-breaks-mcp-servers-uv-command-not-found-18me</link>
      <guid>https://dev.to/clarkbw--/launchds-minimal-path-breaks-mcp-servers-uv-command-not-found-18me</guid>
      <description>&lt;p&gt;A stdio MCP server is just a subprocess. Your agent runtime runs something like&lt;br&gt;
&lt;code&gt;uv run my-mcp-server&lt;/code&gt;, talks to it over stdin/stdout, and exposes its tools to&lt;br&gt;
the model. That works perfectly when you launch the agent from your terminal.&lt;br&gt;
Move the same agent under a launchd service — a &lt;code&gt;LaunchAgent&lt;/code&gt;, a daily job, a&lt;br&gt;
bridge that restarts on crash — and the MCP tools vanish. No crash, no stack&lt;br&gt;
trace, no log line. The model just says it doesn't have the tools. This is the&lt;br&gt;
broke → tried → fixed of why.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;We run a local AI agent (Hermes) that loads several MCP servers over stdio. Each&lt;br&gt;
one is declared in config with a &lt;code&gt;command:&lt;/code&gt; that gets &lt;code&gt;exec&lt;/code&gt;'d as a subprocess.&lt;br&gt;
The natural way to write it — the way that works at the prompt — is to name the&lt;br&gt;
launcher and let &lt;code&gt;PATH&lt;/code&gt; resolve it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent config — mcp_servers&lt;/span&gt;
&lt;span class="na"&gt;mcp_servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;signalk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;-- resolved via PATH&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signalk-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;weather&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weather-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the agent by hand and every tool is live. Then we wired the agent into a&lt;br&gt;
launchd-managed pipeline — a daily job kicked off by a &lt;code&gt;LaunchAgent&lt;/code&gt; — and the&lt;br&gt;
agent came up &lt;em&gt;fine&lt;/em&gt;, answered normally, but reported it had no SignalK or&lt;br&gt;
weather tools. Asked for a sensor reading, the model says some version of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I don't have a tool available to read that.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole symptom. The agent process is healthy. The conversation works.&lt;br&gt;
There is &lt;strong&gt;no error in the agent log, no traceback, no "spawn failed"&lt;/strong&gt; — the MCP&lt;br&gt;
servers simply aren't there. And the tell that sends everyone down the wrong path:&lt;br&gt;
run the exact same agent from an interactive shell and it works every time.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;launchd does not give your process the environment from your shell. It gives it a&lt;br&gt;
&lt;strong&gt;minimal PATH&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;~/.local/bin&lt;/code&gt;. No &lt;code&gt;/usr/local/bin&lt;/code&gt;. No &lt;code&gt;/opt/homebrew/bin&lt;/code&gt;. None of the&lt;br&gt;
&lt;code&gt;PATH&lt;/code&gt; edits from your &lt;code&gt;.zshrc&lt;/code&gt; / &lt;code&gt;.zprofile&lt;/code&gt;, because launchd doesn't source a&lt;br&gt;
login shell. So when launchd &lt;code&gt;exec&lt;/code&gt;s the agent and the agent tries to spawn&lt;br&gt;
&lt;code&gt;command: uv&lt;/code&gt;, the lookup fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;which uv
&lt;span class="gp"&gt;/Users/you/.local/bin/uv      #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in &lt;/span&gt;your shell
&lt;span class="gp"&gt;                              #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;...not on launchd&lt;span class="s1"&gt;'s PATH
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;uv&lt;/code&gt; (installed by the standalone installer) lives in &lt;code&gt;~/.local/bin&lt;/code&gt;. So does the&lt;br&gt;
agent binary itself (&lt;code&gt;hermes&lt;/code&gt;), and plenty of other modern CLIs. Under launchd,&lt;br&gt;
&lt;code&gt;uv&lt;/code&gt; is simply &lt;em&gt;not found&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The reason this is so disorienting is &lt;strong&gt;where&lt;/strong&gt; the failure lands. The agent&lt;br&gt;
launches the MCP server as a child process and treats "couldn't start" as&lt;br&gt;
"server not available" — exactly the same state as a server you didn't configure.&lt;br&gt;
A stdio MCP transport that can't &lt;code&gt;exec&lt;/code&gt; its command doesn't throw up into your&lt;br&gt;
chat loop; it just never produces a tool list. So the model is told nothing is&lt;br&gt;
there, and faithfully reports nothing is there. The root cause (&lt;code&gt;ENOENT&lt;/code&gt; on &lt;code&gt;uv&lt;/code&gt;)&lt;br&gt;
is three layers down and never surfaces.&lt;/p&gt;

&lt;p&gt;Two facts combine into the trap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;launchd's PATH is minimal and doesn't include &lt;code&gt;~/.local/bin&lt;/code&gt;&lt;/strong&gt; — this is by
design (&lt;a href="https://www.launchd.info/" rel="noopener noreferrer"&gt;launchd.info&lt;/a&gt;, and Apple's
&lt;a href="https://keith.github.io/xcode-man-pages/launchd.plist.5.html" rel="noopener noreferrer"&gt;&lt;code&gt;launchd.plist&lt;/code&gt;&lt;/a&gt;
&lt;code&gt;EnvironmentVariables&lt;/code&gt; key is the supported way to change it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failed MCP subprocess spawn degrades silently to "tool unavailable"&lt;/strong&gt; —
no different from a server you never configured.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;p&gt;Because the symptom is "MCP tools missing," every instinct points at the MCP&lt;br&gt;
layer. All of these were dead ends.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 1 — restart the MCP server / restart the agent
&lt;/h3&gt;

&lt;p&gt;The reflex: it's a stale or crashed server, bounce it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# restart the agent's MCP gateway, reload config, retry&lt;/span&gt;
hermes gateway restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: no change. The tools are still missing under launchd, and — the&lt;br&gt;
confusing part — &lt;em&gt;they were never actually crashing&lt;/em&gt;. There was nothing to&lt;br&gt;
restart, because nothing had started. Restarting a process that fails to spawn&lt;br&gt;
just fails to spawn again, identically and silently.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 2 — assume the config / transport is wrong
&lt;/h3&gt;

&lt;p&gt;Next suspicion: a bad server entry, wrong transport, a typo in the command. So we&lt;br&gt;
re-read the config, double-checked the MCP server URLs and args, tried an&lt;br&gt;
HTTP/SSE server instead of stdio to see if stdio was the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;signalk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uv&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signalk-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# looks correct...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: the config is &lt;em&gt;correct&lt;/em&gt;. Run the agent in a terminal with this exact&lt;br&gt;
config and all tools enumerate. The transport is fine; stdio is fine; the args&lt;br&gt;
are fine. Every direct test of the configuration passes — because every direct&lt;br&gt;
test runs in &lt;em&gt;your&lt;/em&gt; shell, with &lt;em&gt;your&lt;/em&gt; PATH.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 3 — chase a phantom server outage
&lt;/h3&gt;

&lt;p&gt;If the agent says the server's unavailable, maybe the server is down. So we went&lt;br&gt;
looking for a crashed backend, a port conflict, a wedged process. There was&lt;br&gt;
nothing to find: the standalone MCP smoke test connects and lists its tools just&lt;br&gt;
fine, because it, too, runs in an interactive shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;hermes mcp &lt;span class="nb"&gt;test &lt;/span&gt;signalk      &lt;span class="c"&gt;# run by hand → works&lt;/span&gt;
✓ connected, 7 tools
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every isolated test passed. That's the signature of this bug, and it's worth&lt;br&gt;
saying plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Interactive shell: works. Under launchd: fails. Same machine, same config,&lt;br&gt;
same binary.&lt;/strong&gt; When you see that split, stop debugging the application and&lt;br&gt;
start debugging the environment — specifically &lt;code&gt;PATH&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We were debugging MCP for an hour. The bug was never in MCP.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Don't rely on &lt;code&gt;PATH&lt;/code&gt; resolution for anything a launchd-managed process spawns.&lt;br&gt;
Use &lt;strong&gt;absolute paths&lt;/strong&gt; in every &lt;code&gt;command:&lt;/code&gt; entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent config — mcp_servers, launchd-safe&lt;/span&gt;
&lt;span class="na"&gt;mcp_servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;signalk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/Users/you/.local/bin/uv&lt;/span&gt;     &lt;span class="c1"&gt;# absolute — no PATH lookup&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signalk-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;weather&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/Users/you/.local/bin/uv&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;weather-mcp-server"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line on why: an absolute path skips &lt;code&gt;PATH&lt;/code&gt; lookup entirely, so launchd's&lt;br&gt;
stripped environment can't break it. (Find the real path with &lt;code&gt;which uv&lt;/code&gt; in your&lt;br&gt;
shell — typically &lt;code&gt;~/.local/bin/uv&lt;/code&gt; for the standalone installer,&lt;br&gt;
&lt;code&gt;/opt/homebrew/bin/uv&lt;/code&gt; under Homebrew on Apple Silicon.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproduce it before you "fix" it.&lt;/strong&gt; This one-liner runs any command under&lt;br&gt;
launchd's exact minimal PATH, so you can confirm the failure (and confirm the&lt;br&gt;
fix) without deploying through launchd:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;/usr/bin/env &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin uv &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# uv: command not found      &amp;lt;-- reproduces the launchd failure&lt;/span&gt;

/usr/bin/env &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin /Users/you/.local/bin/uv &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# uv 0.x.y                   &amp;lt;-- absolute path works&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather keep &lt;code&gt;command: uv&lt;/code&gt; readable, the alternative is to hand launchd a&lt;br&gt;
richer PATH via the plist &lt;code&gt;EnvironmentVariables&lt;/code&gt; key (this affects the whole job,&lt;br&gt;
not just one command):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/Users/you/.local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both work. We chose absolute paths in the command entries because the failure&lt;br&gt;
mode they prevent is silent, and an absolute path is self-documenting: it can't&lt;br&gt;
drift when someone edits their shell profile.&lt;/p&gt;
&lt;h3&gt;
  
  
  The robust version: resolve the path yourself and fail loudly
&lt;/h3&gt;

&lt;p&gt;For subprocesses &lt;em&gt;you&lt;/em&gt; spawn in your own code (a generator, a build step, a&lt;br&gt;
bridge), hardcoding an absolute path is brittle across machines. Better: resolve&lt;br&gt;
via &lt;code&gt;which&lt;/code&gt;, fall back to the known install dir, and &lt;strong&gt;raise a clear error if the&lt;br&gt;
binary genuinely isn't there&lt;/strong&gt; — so a missing tool is loud, not a mystery:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_cli&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find a CLI on PATH, fall back to ~/.local/bin, else fail loudly.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;exe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;which&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;exe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;exe&lt;/span&gt;
    &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;~/.local/bin/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;expanduser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;FileNotFoundError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; not found on PATH or ~/.local/bin — install it &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(e.g. `uv tool install &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;`) or add its dir to PATH.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# subprocess.run([resolve_cli("vessel-knowledge"), "zones", ...])
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;~/.local/bin&lt;/code&gt; fallback covers launchd's stripped PATH; the explicit&lt;br&gt;
&lt;code&gt;FileNotFoundError&lt;/code&gt; turns the &lt;em&gt;next&lt;/em&gt; occurrence of this bug from a silent&lt;br&gt;
"tools unavailable" into a one-line error that names the missing binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This is not specific to MCP, uv, or even macOS.&lt;/strong&gt; Any process spawned by&lt;br&gt;
launchd, &lt;code&gt;cron&lt;/code&gt;, or a systemd unit that resets &lt;code&gt;PATH&lt;/code&gt; (&lt;code&gt;User=&lt;/code&gt; services often&lt;br&gt;
do) gets a stripped environment. Anything that worked because your shell profile&lt;br&gt;
put a directory on &lt;code&gt;PATH&lt;/code&gt; — &lt;code&gt;uv&lt;/code&gt;, &lt;code&gt;pipx&lt;/code&gt; shims, &lt;code&gt;node&lt;/code&gt; via a version manager,&lt;br&gt;
&lt;code&gt;brew&lt;/code&gt;-installed tools — can vanish the moment it's launched by a scheduler&lt;br&gt;
instead of by you. The MCP server is just the messenger.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The real lesson is the silent-failure shape, not the PATH fact.&lt;/strong&gt; Plenty of&lt;br&gt;
people know launchd has a minimal PATH. What costs the hours is that a failed&lt;br&gt;
subprocess spawn surfaces as "feature not available" with no error, two or&lt;br&gt;
three layers up from the cause. When a launchd-run thing behaves as if a whole&lt;br&gt;
capability is missing, suspect a child process that couldn't &lt;code&gt;exec&lt;/code&gt; before you&lt;br&gt;
suspect the capability.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Works in my shell" is not a test of a launchd job.&lt;/strong&gt; Your interactive shell&lt;br&gt;
has a PATH a scheduler never will. Always test scheduled work under the&lt;br&gt;
scheduler's environment — the &lt;code&gt;/usr/bin/env -i PATH=...&lt;/code&gt; one-liner above is the&lt;br&gt;
cheapest way to do it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail loudly when a required binary is missing.&lt;/strong&gt; The fix that prevents the&lt;br&gt;
recurrence isn't the absolute path; it's the &lt;code&gt;FileNotFoundError&lt;/code&gt;. A tool that&lt;br&gt;
silently degrades when a dependency is absent will cost someone this exact hour&lt;br&gt;
again. Make "binary not found" a loud, specific error.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This came out of building an AI ops layer for an all-electric charter catamaran —&lt;br&gt;
a local LLM, SignalK, and a stack of MCP servers, with a daily briefing kicked&lt;br&gt;
off by a launchd job. "Why does the agent lose its tools only when the scheduler&lt;br&gt;
starts it" turned out to be a one-word answer: &lt;code&gt;PATH&lt;/code&gt;. The MCP servers behind it&lt;br&gt;
are open source — &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-mcp&lt;/a&gt;&lt;br&gt;
is a representative stdio server, and the rest live at&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>macos</category>
      <category>launchd</category>
      <category>mcp</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>No audio from the Home Assistant Nabu puck: use assist_satellite.announce</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:41:31 +0000</pubDate>
      <link>https://dev.to/clarkbw--/no-audio-from-the-home-assistant-nabu-puck-use-assistsatelliteannounce-li3</link>
      <guid>https://dev.to/clarkbw--/no-audio-from-the-home-assistant-nabu-puck-use-assistsatelliteannounce-li3</guid>
      <description>&lt;p&gt;You wire up a local voice pipeline on a Home Assistant Voice (Nabu) puck, fire a &lt;code&gt;tts.speak&lt;/code&gt; at it from an automation, and nothing comes out. The pipeline runs. Home Assistant reports the TTS call succeeded. There is no speech. Worse, when you poke at it in Developer Tools, the &lt;code&gt;media_player&lt;/code&gt; entity flips to &lt;code&gt;playing&lt;/code&gt; exactly like you'd expect — so it &lt;em&gt;looks&lt;/em&gt; like it's working, and you spend an hour staring at a service call that does nothing audible.&lt;/p&gt;

&lt;p&gt;The root cause: the puck isn't a plain media player. It's an &lt;code&gt;assist_satellite&lt;/code&gt; entity, and that changes how you have to route TTS. Get past that and there are two follow-on traps — announcing into a busy satellite, and a silently-crashed Piper add-on that masquerades as the original "no speech" symptom. Here's the full broke → tried → fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;You push text to the puck and it never speaks. Search terms for the person hitting this right now: &lt;em&gt;Home Assistant Nabu voice no audio&lt;/em&gt;, &lt;em&gt;Voice PE tts.speak no sound&lt;/em&gt;, &lt;em&gt;assist_satellite no speech media_player&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The obvious service call — speak some text to the puck's media player:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tts.speak&lt;/span&gt;
&lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tts.piper&lt;/span&gt;          &lt;span class="c1"&gt;# your TTS engine entity&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;media_player_entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;media_player.home_voice&lt;/span&gt;   &lt;span class="c1"&gt;# the puck&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Depth&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4.2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;keel,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Captain."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Developer Tools → States, &lt;code&gt;media_player.home_voice&lt;/code&gt; dutifully flips to &lt;code&gt;playing&lt;/code&gt; and shows the media metadata. The speaker stays silent. This is the trap: &lt;strong&gt;the state change is real, the audio is not.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;The puck is not a plain media player. The Home Assistant Voice puck runs ESPHome, and the &lt;strong&gt;ESPHome integration exposes it as an &lt;a href="https://developers.home-assistant.io/docs/core/entity/assist-satellite/" rel="noopener noreferrer"&gt;&lt;code&gt;assist_satellite&lt;/code&gt;&lt;/a&gt; entity&lt;/strong&gt; — an entity "which represents a voice satellite, with its state following the underlying Assist pipeline." Both the &lt;code&gt;esphome&lt;/code&gt; and &lt;code&gt;voip&lt;/code&gt; integrations were &lt;a href="https://developers.home-assistant.io/blog/2024/10/01/assist-satellite-entity/" rel="noopener noreferrer"&gt;transitioned to &lt;code&gt;AssistSatelliteEntity&lt;/code&gt;&lt;/a&gt;. This is true the moment the device is adopted; it has nothing to do with installing the Whisper add-on. (Whisper is just one way to give the pipeline local STT — Speech-to-Phrase and Home Assistant Cloud are the other two. None of them is what creates the satellite entity.) If you only realized the puck was a satellite around the time you set up local STT, that's coincidence, not cause.&lt;/p&gt;

&lt;p&gt;The satellite owns its own audio path, driven by its configured pipeline. &lt;code&gt;tts.speak&lt;/code&gt; with &lt;code&gt;media_player_entity_id&lt;/code&gt; targets the generic media-player surface instead. In our setup, that surface is effectively vestigial on the satellite — HA updates the &lt;code&gt;media_player&lt;/code&gt; entity state to &lt;code&gt;playing&lt;/code&gt;, but no audio comes out, because nothing routed the speech through the satellite's announcement path. No error, no log line, just silence. (We're not the only ones who've watched &lt;code&gt;tts.speak&lt;/code&gt; to a satellite go quiet — the community "&lt;a href="https://community.home-assistant.io/t/still-cant-make-tts-speak-work/703522" rel="noopener noreferrer"&gt;still can't make tts.speak work&lt;/a&gt;" threads are full of it — but the exact behavior depends on your device and TTS engine, so treat this as our-setup observation, not a documented contract.)&lt;/p&gt;

&lt;p&gt;The action that &lt;em&gt;does&lt;/em&gt; route through the satellite's audio path is &lt;a href="https://www.home-assistant.io/integrations/assist_satellite/" rel="noopener noreferrer"&gt;&lt;code&gt;assist_satellite.announce&lt;/code&gt;&lt;/a&gt;, which converts the message to a media id "using the text-to-speech system of the satellite's configured pipeline."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dead-end 1: &lt;code&gt;tts.speak&lt;/code&gt; to the media_player entity
&lt;/h3&gt;

&lt;p&gt;The call above. Result: &lt;code&gt;media_player.home_voice&lt;/code&gt; shows &lt;code&gt;playing&lt;/code&gt;, attributes populate, &lt;strong&gt;zero audio&lt;/strong&gt;. No exception in the logs. This is the most expensive dead-end precisely because it half-works — the state machine lies to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead-end 2: immediate ack + async response (two announces)
&lt;/h3&gt;

&lt;p&gt;Once you switch to &lt;code&gt;assist_satellite.announce&lt;/code&gt;, the next instinct is conversational polish: speak an instant "One moment" so the user knows they were heard, then speak the real answer when the slow work (a tide lookup, an LLM round-trip) finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# DON'T — two announces fired without waiting for the first to finish&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.announce&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.home_voice&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;moment,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Captain."&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.announce&lt;/span&gt;      &lt;span class="c1"&gt;# fires while #1 is still going&lt;/span&gt;
  &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.home_voice&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;answer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trouble is timing. The docs say &lt;a href="https://developers.home-assistant.io/docs/core/entity/assist-satellite/" rel="noopener noreferrer"&gt;&lt;code&gt;async_announce&lt;/code&gt;&lt;/a&gt; "should only return when the announcement is finished playing on the device," and that the satellite stays in &lt;code&gt;responding&lt;/code&gt; until something calls &lt;code&gt;tts_response_finished&lt;/code&gt; to bring it back to &lt;code&gt;idle&lt;/code&gt;. So the second &lt;code&gt;announce&lt;/code&gt; lands while the satellite is still &lt;code&gt;responding&lt;/code&gt; from the first — and in our testing that's where it goes wrong: the second announce gets dropped or the satellite wedges.&lt;/p&gt;

&lt;p&gt;How much of this is "documented bug" vs. "our setup" is worth being precise about, because the upstream picture is messier than it first looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There &lt;em&gt;is&lt;/em&gt; a report of an &lt;a href="https://github.com/home-assistant/core/issues/142363" rel="noopener noreferrer"&gt;assist satellite stuck in the "responding" state&lt;/a&gt; (core #142363) — but it's about the satellite failing to return to &lt;code&gt;idle&lt;/code&gt; after a normal conversation, and it was &lt;strong&gt;closed as not planned&lt;/strong&gt;, so don't read it as a confirmed fix or as proof that announcing-into-&lt;code&gt;responding&lt;/code&gt; deadlocks. It just establishes that the &lt;code&gt;responding&lt;/code&gt; state can hang.&lt;/li&gt;
&lt;li&gt;There's a community thread, "&lt;a href="https://community.home-assistant.io/t/issue-executing-two-assist-satellite-announce-in-a-script-fails/931121" rel="noopener noreferrer"&gt;executing two assist_satellite.announce in a script fails&lt;/a&gt;," but read it carefully: the failure was specific to a script &lt;strong&gt;called from a voice intent&lt;/strong&gt;, and the diagnosed cause was mixing &lt;code&gt;announce&lt;/code&gt; with &lt;code&gt;set_conversation_response&lt;/code&gt; on the same device — &lt;em&gt;not&lt;/em&gt; two announces colliding in general. The same two-announce script ran fine from an automation or Developer Tools. So it's a real gotcha, but a narrower one than "two announces always conflict."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bottom line: the reliable rule that fixed it for us is &lt;strong&gt;one announce, fired only when the satellite is &lt;code&gt;idle&lt;/code&gt;&lt;/strong&gt; — see the fix. We treat "announcing into &lt;code&gt;responding&lt;/code&gt; wedges the satellite" as our-setup behavior, consistent with the docs' state machine but not something upstream has labeled a bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead-end 3: everything's correct and it's &lt;em&gt;still&lt;/em&gt; silent
&lt;/h3&gt;

&lt;p&gt;You've got &lt;code&gt;assist_satellite.announce&lt;/code&gt;, a single call, fired on idle — and after some unrelated restart it goes mute again. Same signature as the very first problem: pre-announce chime, then no speech. You re-check your service call. It's fine.&lt;/p&gt;

&lt;p&gt;It's not your call. The &lt;strong&gt;Piper add-on had crashed&lt;/strong&gt; and never came back. TTS has no engine, so the message never gets synthesized and nothing speaks. Piper crashing on TTS calls is not just us: &lt;a href="https://github.com/home-assistant/addons/issues/3379" rel="noopener noreferrer"&gt;Piper: Crash after any TTS call&lt;/a&gt; (addons #3379) reports exactly this — &lt;code&gt;ConnectionResetError&lt;/code&gt; then a &lt;code&gt;FileNotFoundError&lt;/code&gt; on an empty audio path, i.e. the synthesis never produced a file. With no watchdog, a crashed Piper stays down and there's no obvious symptom pointing at it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note on the chime: &lt;code&gt;assist_satellite.announce&lt;/code&gt; plays a pre-announce chime that is itself a media id, and the Voice puck also has its own on-device wake/feedback sounds. We saw the device still making &lt;em&gt;a&lt;/em&gt; sound while speech was dead, which is what made this so confusing — but whether a given chime survives a dead Piper depends on where that chime comes from. Don't over-read it; the reliable tell is below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three things, all small:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Use &lt;code&gt;assist_satellite.announce&lt;/code&gt;, targeting the satellite entity:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.announce&lt;/span&gt;
&lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;entity_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assist_satellite.home_voice&lt;/span&gt;   &lt;span class="c1"&gt;# your puck's assist_satellite entity&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Depth&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4.2&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metres&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;keel,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Captain."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Substitute your own entity ID — it's machine-local. Find it in Developer Tools → States by filtering on &lt;code&gt;assist_satellite.&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. One announce, on idle only.&lt;/strong&gt; Drop the immediate-ack pattern. Fire a single &lt;code&gt;announce&lt;/code&gt; with the &lt;em&gt;final&lt;/em&gt; response, and only when the satellite is idle — never while it's &lt;code&gt;responding&lt;/code&gt;. If you need a "thinking" cue, do it without a second announce (a UI/light cue, or let the pipeline's own response carry it).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Turn on the Piper add-on watchdog.&lt;/strong&gt; Settings → Add-ons → Piper → enable &lt;strong&gt;Watchdog&lt;/strong&gt;. (This is our mitigation, not something the upstream issue prescribes.) Without it, a crashed Piper stays down and takes all TTS with it until you notice.&lt;/p&gt;

&lt;p&gt;And the one diagnostic that saves the most time: &lt;strong&gt;when speech goes mute, check the Piper add-on status before you touch your automation.&lt;/strong&gt; Speech depends on Piper; if Piper is down, no &lt;code&gt;announce&lt;/code&gt; and no &lt;code&gt;tts.speak&lt;/code&gt; will ever produce audio, no matter how correct the call is. The Piper add-on log (&lt;code&gt;ConnectionResetError&lt;/code&gt;, &lt;code&gt;FileNotFoundError&lt;/code&gt; on an empty path) is the giveaway. Don't trust a chime as proof the audio path is healthy — see the gotcha below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State changes are not proof of audio.&lt;/strong&gt; &lt;code&gt;tts.speak&lt;/code&gt; to a satellite's media_player entity updating to &lt;code&gt;playing&lt;/code&gt; tells you nothing about whether sound came out. On &lt;code&gt;assist_satellite&lt;/code&gt; devices, trust your ears (or the satellite's own state), not the media_player entity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The puck is a satellite from the start.&lt;/strong&gt; The ESPHome integration exposes the Voice puck as an &lt;code&gt;assist_satellite&lt;/code&gt; the moment you adopt it — local STT (Whisper, Speech-to-Phrase, or Cloud) has nothing to do with it. So target &lt;code&gt;assist_satellite.announce&lt;/code&gt; from day one; a &lt;code&gt;tts.speak&lt;/code&gt;-to-media_player automation may never have made sound on this device, you just didn't notice until you needed it to talk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;responding&lt;/code&gt; as busy.&lt;/strong&gt; Per the docs, a satellite stays in &lt;code&gt;responding&lt;/code&gt; until &lt;code&gt;tts_response_finished&lt;/code&gt; returns it to &lt;code&gt;idle&lt;/code&gt;. Fire your &lt;code&gt;announce&lt;/code&gt; only on &lt;code&gt;idle&lt;/code&gt;. Better still, where you can, let the normal conversation pipeline return the speech response — it flows back through the satellite without you hand-rolling an announce at all. (And note &lt;code&gt;responding&lt;/code&gt; can itself hang — core #142363 — independent of anything you do.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust a chime.&lt;/strong&gt; Something on the device may keep chiming while speech is stone dead, which is exactly what sends you debugging your automation instead of the TTS engine. Whether a particular chime survives a dead Piper depends on where it's generated, so don't reason from it — check Piper.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;This came out of building a local voice front-end for the boat-agent stack on &lt;em&gt;Sailing Naturali&lt;/em&gt; — an all-electric charter catamaran where the helm talks to a local LLM through a Home Assistant Voice puck. The agent and MCP tooling are open source under &lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt;; the voice front-end notes live alongside them.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>selfhosted</category>
      <category>voiceassistant</category>
      <category>ai</category>
    </item>
    <item>
      <title>Route all Home Assistant voice to a custom agent with a wildcard sentence</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:41:28 +0000</pubDate>
      <link>https://dev.to/clarkbw--/route-all-home-assistant-voice-to-a-custom-agent-with-a-wildcard-sentence-4iee</link>
      <guid>https://dev.to/clarkbw--/route-all-home-assistant-voice-to-a-custom-agent-with-a-wildcard-sentence-4iee</guid>
      <description>&lt;p&gt;If you want &lt;em&gt;every&lt;/em&gt; 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 &lt;code&gt;MissingListError&lt;/code&gt;, and the queries that &lt;em&gt;do&lt;/em&gt; parse get silently hijacked by built-in intents. This is the broke → tried → fixed of getting it working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;conversation&lt;/code&gt; trigger with a bare wildcard:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Speak anything and the conversation API returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 500 Internal Server Error
hassil.errors.MissingListError: Missing slot list {text}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the requests that &lt;em&gt;don't&lt;/em&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'm sorry, no device is playing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;code&gt;MediaPlayerNext&lt;/code&gt; matching on "next." Other built-ins join in: &lt;code&gt;HassGetState&lt;/code&gt; grabs "when is {name}", &lt;code&gt;HassTurnOn&lt;/code&gt; grabs "turn on {name}", &lt;code&gt;HassFanSetSpeed&lt;/code&gt; grabs "[set] {name} [to] {speed}". Your custom routing never gets a look in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;Two separate things are happening, and it's easy to conflate them.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;2. The hijacking is intent ordering.&lt;/strong&gt; 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 — &lt;code&gt;MediaPlayerNext&lt;/code&gt; matches "next [anything]", &lt;code&gt;HassGetState&lt;/code&gt; matches "when is …", and so on. Whoever matches first wins, and the built-ins were matching first.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attempt 1 — automation &lt;code&gt;conversation&lt;/code&gt; trigger with a bare wildcard
&lt;/h3&gt;



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

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hassil.errors.MissingListError: Missing slot list {text}
→ HTTP 500 on the conversation API
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Attempt 2 — narrow the greedy built-ins instead
&lt;/h3&gt;

&lt;p&gt;If general queries are being eaten by &lt;code&gt;MediaPlayerNext&lt;/code&gt; and friends, maybe just redefine those built-ins to be less greedy and let everything else fall through:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;This &lt;em&gt;did&lt;/em&gt; stop "when is the next low tide" from hitting &lt;code&gt;MediaPlayerNext&lt;/code&gt; — narrowing the sentences works. But it's whack-a-mole: every broad built-in (&lt;code&gt;HassGetState&lt;/code&gt;, &lt;code&gt;HassTurnOn&lt;/code&gt;, &lt;code&gt;HassFanSetSpeed&lt;/code&gt;, …) 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move the catch-all out of the automation trigger and into a &lt;strong&gt;custom sentence&lt;/strong&gt; with a real wildcard list, then handle the intent with &lt;code&gt;intent_script&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;custom_sentences/en/naturali.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en"&lt;/span&gt;
&lt;span class="na"&gt;intents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NaturaliQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;sentences&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{text}"&lt;/span&gt;
&lt;span class="na"&gt;lists&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;wildcard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;configuration.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;intent_script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;NaturaliQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;speech&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;One&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;moment,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Captain."&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mqtt.publish&lt;/span&gt;
        &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;naturali/intents/ask&lt;/span&gt;
          &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;{"text":&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this work where the trigger couldn't:&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;intent_script&lt;/code&gt; (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 &lt;code&gt;{{ text }}&lt;/code&gt; in &lt;code&gt;intent_script&lt;/code&gt; — not &lt;code&gt;{{ trigger.slots.text }}&lt;/code&gt;; there's no trigger object here.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Why it matters / gotchas
&lt;/h2&gt;

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

&lt;p&gt;Other things worth knowing:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Close
&lt;/h2&gt;

&lt;p&gt;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: &lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>homeassistant</category>
      <category>selfhosted</category>
      <category>voiceassistant</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
