<?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>Your remote coding agent has the repo, not the machine — Tailscale is the missing half</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 11 Jul 2026 16:04:55 +0000</pubDate>
      <link>https://dev.to/clarkbw--/your-remote-coding-agent-has-the-repo-not-the-machine-tailscale-is-the-missing-half-k4d</link>
      <guid>https://dev.to/clarkbw--/your-remote-coding-agent-has-the-repo-not-the-machine-tailscale-is-the-missing-half-k4d</guid>
      <description>&lt;p&gt;You can now run a coding agent as a &lt;em&gt;remote session&lt;/em&gt; — a cloud sandbox that clones your repo, edits across every package, runs the tests, and opens the PR, all driven from a phone app while you're nowhere near a desk. It's genuinely good. Then you ask it to show you the proof-of-concept actually running, and nothing happens.&lt;/p&gt;

&lt;p&gt;That's not a bug. The session has your &lt;strong&gt;repo&lt;/strong&gt;. It never had your &lt;strong&gt;machine&lt;/strong&gt;. The repo is portable — it travels into any sandbox as a git clone. The &lt;em&gt;running system&lt;/em&gt; — your dev server on &lt;code&gt;localhost&lt;/code&gt;, the service on your LAN, the device behind your home router — does not. Most of the time the repo is all the agent needs. The exception is the one that matters: anything you want to &lt;em&gt;look at running&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is framework-general. It applies to any cloud-hosted agent session — &lt;a href="https://docs.claude.com/en/docs/claude-code/overview" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; remote sessions, cloud dev environments, an agent in a CI runner. The shape is always the same: portable code, stranded runtime.&lt;/p&gt;

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

&lt;p&gt;Our org is ~20 repos — SignalK plugins, MCP servers, Home Assistant config, the web app. A remote session anchored at the top-level repo, with a workspace &lt;code&gt;CLAUDE.md&lt;/code&gt; that the sibling repos inherit, gives the agent cross-repo context for free. Reading, refactoring, testing, PRs — none of that needs anything but the code. It works beautifully from the dock.&lt;/p&gt;

&lt;p&gt;Then the work produces something you have to &lt;em&gt;see&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a web POC whose dev server you actually want to click through&lt;/li&gt;
&lt;li&gt;a live service to hit — in our case the boat's SignalK server on the Pi, plus Home Assistant&lt;/li&gt;
&lt;li&gt;anything bound to &lt;code&gt;localhost&lt;/code&gt; on the real box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cloud sandbox can reach none of it. The Pi is the sharp version: it sits behind Starlink, which is &lt;a href="https://tailscale.com/blog/how-nat-traversal-works" rel="noopener noreferrer"&gt;CGNAT&lt;/a&gt; — there is no inbound route to it from the public internet &lt;em&gt;at all&lt;/em&gt;. But even the Studio in the next room is unreachable from a sandbox that only has your source tree. Different distances, same wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two planes
&lt;/h2&gt;

&lt;p&gt;The fix starts as a naming exercise. Stop treating "the work" as one thing. There are two planes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Driving the work&lt;/strong&gt; — repo-bounded, runs anywhere. Phone app, cloud session, no machine required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The running system&lt;/strong&gt; — a real host plus its network. The dev server, the LAN service, the device.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The remote agent owns plane 1 completely. Plane 2 needs an actual box. The mistake is expecting the cloud session to deliver both — it structurally can't, and editing prompts won't change that. What you need is a path &lt;em&gt;from where you are&lt;/em&gt; back to the machine that runs things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bridge
&lt;/h2&gt;

&lt;p&gt;A per-host &lt;a href="https://tailscale.com/kb/1136/tailnet" rel="noopener noreferrer"&gt;tailnet&lt;/a&gt; puts the real machines and your phone on one private network. No subnet routes, no public exposure, no port-forwarding — each host joins individually and gets a stable address and a MagicDNS name:&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;tailscale status
&lt;span class="go"&gt;100.126.222.111  studio         clarkbw@  macOS  -
100.104.78.15    homeassistant  clarkbw@  linux  -
100.92.194.27    iphone181      clarkbw@  iOS    -
100.115.37.115   naturalaspi    clarkbw@  linux  -
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the POC runs on the real machine — the Studio — and you reach it from the phone. Bind the dev server to all interfaces and hit the MagicDNS name:&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;# on the Studio&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8911 &lt;span class="nt"&gt;--bind&lt;/span&gt; 0.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;from the phone &lt;span class="o"&gt;(&lt;/span&gt;or anywhere on the tailnet&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl http://studio.tailb19444.ts.net:8911/
&lt;span class="gp"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;POC reachable over the tailnet&amp;lt;/h1&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick. The cloud session does the repo work and lands the branch; the Studio runs it; the tailnet carries the one hop from your phone to the running thing. Same tunnel reaches &lt;code&gt;naturalaspi&lt;/code&gt; — the SignalK server the public internet can't touch — because to the tailnet, CGNAT isn't there. The boat being unreachable from the outside was never a special case; it's just the most extreme point on the same line, and the bridge that fixes the Studio fixes the boat too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why anchor at the org repo
&lt;/h2&gt;

&lt;p&gt;One detail makes the remote half pull its weight: anchor the session at the &lt;strong&gt;top-level org repo&lt;/strong&gt;, not an individual package. A workspace &lt;code&gt;CLAUDE.md&lt;/code&gt; there — inherited by every sibling via a symlink — means one session reasons across all ~20 repos instead of one. The agent that just edited a plugin already knows where the MCP server and the HA config live. Without the anchor you're back to one-repo-at-a-time, and the remote session stops being worth driving from a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;A remote coding agent is two things welded together, and it only ships one of them to the cloud. Give it the &lt;strong&gt;repo&lt;/strong&gt; &lt;em&gt;and&lt;/em&gt; a &lt;strong&gt;tunnel home&lt;/strong&gt; — anchor the session at the org repo so it spans everything, and put your machines plus your phone on a tailnet so "go look at it running" is one hop away. Do both and dev-from-the-dock stops being a demo. Do only the first and you'll keep hitting the wall where the code is right there and the running system is nowhere.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>tailscale</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Monitor the delivery path, not just the alarm</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Thu, 09 Jul 2026 04:50:48 +0000</pubDate>
      <link>https://dev.to/clarkbw--/monitor-the-delivery-path-not-just-the-alarm-21fl</link>
      <guid>https://dev.to/clarkbw--/monitor-the-delivery-path-not-just-the-alarm-21fl</guid>
      <description>&lt;p&gt;We built a &lt;a href="https://engineering.sailingnaturali.com/signalk-ntfy-push-notifications-to-phone-zero-dependency-relay/" rel="noopener noreferrer"&gt;zero-dependency SignalK→ntfy relay&lt;/a&gt; so a man-overboard, low-battery, or depth alarm reaches a phone even when nobody's at the chartplotter. It worked. Then, a month later, it silently stopped working, and we found out &lt;strong&gt;by accident&lt;/strong&gt; — while smoke-testing an entirely different plugin, this line scrolled past in the SignalK server log:&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="go"&gt;ntfy responded 401
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every alarm the relay had tried to push was getting a &lt;code&gt;401 Unauthorized&lt;/code&gt; from ntfy and evaporating. The alarm-to-phone path had been dark for who-knows-how-long, and nothing had told us. On a boat, "your emergency notifications quietly stopped delivering" is the exact failure you cannot afford to discover during the emergency.&lt;/p&gt;

&lt;p&gt;This is the broke → tried → fixed of a blind spot that isn't marine at all: &lt;strong&gt;you monitor the thing, but not the pipe that tells you about the thing.&lt;/strong&gt; Anyone running a self-hosted alert pipeline — ntfy, Gotify, Pushover, Home Assistant notifications, a PagerDuty webhook — has the same hole.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a failure with nothing to fail
&lt;/h2&gt;

&lt;p&gt;Here's the whole chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SignalK notification  →  signalk-ntfy-relay  →  ntfy  →  phone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay's send path is a fire-and-forget POST. By design it never throws — one bad push must not stall the next alarm — so a non-2xx response just gets logged:&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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lib&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;u&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// drain&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&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="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ntfy responded &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&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;p&gt;That log line &lt;em&gt;is&lt;/em&gt; the entire failure signal. And nothing consumes it.&lt;/p&gt;

&lt;p&gt;Now sit with the nastiest property of this failure mode: &lt;strong&gt;the relay only sends when an alarm fires.&lt;/strong&gt; A boat at anchor on a calm night fires no alarms, so there are no sends, so there is nothing to fail, so the broken path stays perfectly invisible. An expired token isn't discovered until the next real MOB or EPIRB event tries to deliver — which is precisely the moment you needed it to already be working.&lt;/p&gt;

&lt;p&gt;A per-send failure log is not monitoring. It's a receipt for a delivery that already didn't happen, filed in a drawer nobody opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis: isolate the leg that's failing
&lt;/h2&gt;

&lt;p&gt;A 401 could be a lot of things — wrong topic, a publish-path bug, a reverse proxy eating the &lt;code&gt;Authorization&lt;/code&gt; header (a &lt;a href="https://github.com/binwiederhier/ntfy/issues/650" rel="noopener noreferrer"&gt;common ntfy 401 cause&lt;/a&gt;). We wanted to isolate it to one leg without firing a fake MOB every time. ntfy has a read-only account endpoint, so a token-only probe answers "is the token still good?" with zero side effects:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H "Authorization: Bearer tk_..." https://ntfy.sh/v1/account
401
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;401&lt;/code&gt; on a bare &lt;code&gt;GET /v1/account&lt;/code&gt; — no topic, no publish, no message body involved. That isolates it cleanly: not the topic, not the relay's publish code, &lt;strong&gt;the token itself.&lt;/strong&gt; ntfy.sh account tokens can carry an expiry, and this one had lapsed. Our topic is reserved, so publishing to it &lt;em&gt;requires&lt;/em&gt; a valid token — an expired token means every publish is a 401, forever, until someone notices.&lt;/p&gt;

&lt;p&gt;Root cause found. But the root cause was never the interesting part. The interesting part is that a token expiring is a totally ordinary event, and our system's response to it was &lt;strong&gt;silence&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we tried first (and why it wasn't enough)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1 — just mint a non-expiring token.&lt;/strong&gt; ntfy lets you create a token that never expires:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer tk_old..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    -H "Content-Type: application/json" \
    -d '{"label":"signalk-relay","expires":0}' \
    https://ntfy.sh/v1/account/token
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes the &lt;em&gt;most likely&lt;/em&gt; cause, and it's worth doing — prefer eliminating a failure mode over monitoring it. But it's not a fix for the blind spot. A token can still be revoked, an ACL can change, ntfy.sh can have an outage, DNS can break. Every one of those reproduces the exact same silent-dark path. Removing the top cause narrows the hole; it doesn't close it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2 — alarm on the first failed send.&lt;/strong&gt; Tempting, but wrong. Auth failures are permanent, but a lot of send failures are transient — a dropped connection, a 5-second timeout, a momentary ntfy hiccup. Alarming on the first blip would cry wolf constantly on a marine link that drops all the time. The signal we actually want is &lt;em&gt;sustained&lt;/em&gt; failure, not one bad POST.&lt;/p&gt;

&lt;p&gt;Neither attempt gives the system its own pulse. That's the real requirement: &lt;strong&gt;the notification pipeline needs a health check separate from the events it carries.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a delivery-path health check inside the relay
&lt;/h2&gt;

&lt;p&gt;Two mechanisms, one shared counter. Both live in the relay itself — no external watchdog service to also keep alive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proactive heartbeat.&lt;/strong&gt; On an interval (default 24h), probe the path even when no alarms are firing. This is the piece that catches an expired token &lt;em&gt;between&lt;/em&gt; alarms, on the quiet night before the emergency:&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="c1"&gt;// Read-only auth/reachability probe: GET {server}/v1/account.&lt;/span&gt;
&lt;span class="c1"&gt;// Catches an expired/revoked token (or an unreachable server) proactively,&lt;/span&gt;
&lt;span class="c1"&gt;// before a real alarm needs the path. Never throws.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;defaultCheckAccount&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;token&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;cb&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;u&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;/v1/account`&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;lib&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;https&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&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="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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lib&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;u&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;GET&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&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;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;e&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;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 account check failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&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="nf"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&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;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ntfy account check timeout&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reactive counting.&lt;/strong&gt; Every real send now reports its outcome, and consecutive failures accumulate. Both paths feed one function that alarms only past a threshold (default 3) — riding out the transient blips that killed attempt 2:&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="c1"&gt;// A run of `failureThreshold` consecutive failures raises the delivery-path&lt;/span&gt;
&lt;span class="c1"&gt;// alarm; any success resets and clears it.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;)&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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;consecutiveFailures&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deliveryFailedRaised&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;deliveryFailedRaised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nf"&gt;setDeliveryFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;consecutiveFailures&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&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;consecutiveFailures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;failureThreshold&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;deliveryFailedRaised&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;deliveryFailedRaised&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setDeliveryFailed&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="nx"&gt;detail&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;&lt;strong&gt;Where the alarm surfaces is the whole point.&lt;/strong&gt; It must not go out through the very path that's broken — pushing "the push path is down" to the down phone is a no-op. So the relay raises it as a &lt;em&gt;SignalK notification&lt;/em&gt; under its own name:&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;DELIVERY_FAILED_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.ntfyRelay.deliveryFailed&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;setDeliveryFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;detail&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="nf"&gt;handleMessage&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;values&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="nx"&gt;DELIVERY_FAILED_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert&lt;/span&gt;&lt;span class="dl"&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;visual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`ntfy delivery path failing&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;detail&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;detail&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="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — alarms are not reaching the phone`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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="na"&gt;state&lt;/span&gt;&lt;span class="p"&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&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="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 lands on the SignalK dashboard and our voice pipeline — &lt;strong&gt;channels independent of the phone that's down.&lt;/strong&gt; And it carries one deliberate exclusion: the relay subscribes to &lt;code&gt;notifications.*&lt;/code&gt;, so its &lt;em&gt;own&lt;/em&gt; delivery-failed notification would otherwise get forwarded straight back to the failing ntfy path. Drop it:&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="c1"&gt;// Never forward our own delivery-path alarm — it must not loop through the&lt;/span&gt;
&lt;span class="c1"&gt;// failing ntfy path (it surfaces via the dashboard/voice instead).&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ntfyRelay.&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test-first, as always — four failing tests before any of this existed (threshold behaviour, clear-on-success + counter reset, loop-avoidance, reactive counting):&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="go"&gt;✔ health check raises deliveryFailed only after failureThreshold consecutive failures
✔ a success clears deliveryFailed and resets the failure counter
✔ the deliveryFailed notification is never forwarded to ntfy (loop-avoidance)
✔ reactive send failures count toward the deliveryFailed threshold
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shipped in &lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ntfy-relay&lt;/code&gt; v0.2.0&lt;/a&gt;, two new config knobs, both with safe defaults:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;healthCheckIntervalHours&lt;/span&gt;   &lt;span class="err"&gt;24&lt;/span&gt;   &lt;span class="err"&gt;Proactively&lt;/span&gt; &lt;span class="err"&gt;probe&lt;/span&gt; &lt;span class="err"&gt;/v1/account;&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;disables.&lt;/span&gt; &lt;span class="err"&gt;Token-only.&lt;/span&gt;
&lt;span class="err"&gt;failureThreshold&lt;/span&gt;            &lt;span class="err"&gt;3&lt;/span&gt;   &lt;span class="err"&gt;Consecutive&lt;/span&gt; &lt;span class="err"&gt;failures&lt;/span&gt; &lt;span class="err"&gt;before&lt;/span&gt; &lt;span class="err"&gt;raising&lt;/span&gt; &lt;span class="err"&gt;the&lt;/span&gt; &lt;span class="err"&gt;delivery-path&lt;/span&gt; &lt;span class="err"&gt;alarm.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also added a small diagnostics CLI, &lt;code&gt;scripts/ntfy-doctor.js&lt;/code&gt;, so the path can be verified on demand instead of waiting for a real alarm — &lt;code&gt;check&lt;/code&gt; (the &lt;code&gt;/v1/account&lt;/code&gt; probe), &lt;code&gt;test&lt;/code&gt; (publish + poll to confirm end-to-end delivery), &lt;code&gt;poll&lt;/code&gt;, &lt;code&gt;mint&lt;/code&gt;, and &lt;code&gt;revoke&lt;/code&gt;:&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;node scripts/ntfy-doctor.js check &lt;span class="nt"&gt;--config&lt;/span&gt; .../signalk-ntfy-relay.json
&lt;span class="go"&gt;/v1/account: 401
✗ token invalid/expired/revoked — mint a new one on ntfy.sh
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A signal nobody consumes is not monitoring.&lt;/strong&gt; &lt;code&gt;app.error('ntfy responded 401')&lt;/code&gt; felt like we'd "handled" the failure. We hadn't — we'd written it down. If no code path acts on a failure log, the failure is silent no matter how loudly it's logged. This is the &lt;a href="https://oneuptime.com/blog/post/2026-02-06-heartbeat-dead-man-switch-opentelemetry-pipeline/view" rel="noopener noreferrer"&gt;Watchdog / dead-man's-switch pattern&lt;/a&gt; that Prometheus/Alertmanager users know well ("who watches the watcher?") — the twist is applying it to the &lt;em&gt;outbound&lt;/em&gt; leg, and doing it &lt;em&gt;inside the relay&lt;/em&gt; rather than bolting on a second service you'd also have to keep alive.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Absence of failure is not proof of health.&lt;/strong&gt; The trap specific to alert pipelines: they're exercised only when something's already wrong. A quiet week generates zero sends, so zero failures, so a false all-clear. That's why the proactive heartbeat matters more than the reactive counter — it manufactures traffic on purpose, so the path is exercised on the calm night, not for the first time during the MOB.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Alarm on a threshold, not the first failure.&lt;/strong&gt; Auth failures are permanent; network blips aren't. &lt;code&gt;N&lt;/code&gt; consecutive failures rides out the transient and still catches the durable — a single knob (&lt;code&gt;failureThreshold&lt;/code&gt;) that separates "the token expired" from "the wifi flickered."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never route the health alarm through the failing path.&lt;/strong&gt; Push "the push path is down" to the down phone and it goes nowhere. The delivery-failed signal has to surface on an &lt;em&gt;independent&lt;/em&gt; channel — for us the dashboard and voice, for you maybe a log-scraper, a second provider, or an LED. And exclude it from the pipe it describes, or it loops.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Prefer removing the failure mode over monitoring it — then monitor the rest.&lt;/strong&gt; The non-expiring token deletes the single likeliest cause outright. The health check then covers the long tail nothing can pre-empt: revocation, ACL change, provider outage. Belt &lt;em&gt;and&lt;/em&gt; suspenders, in that order.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general shape, provider-agnostic: any pipeline whose job is to tell you when something's wrong needs its own out-of-band pulse, because it is exercised only in the moment you most need it and least want to be discovering it's broken.&lt;/p&gt;

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

&lt;p&gt;This came from running the alerting layer of an all-electric charter catamaran, where a silently-dead man-overboard notification is worse than none at all — at least a missing feature is a known gap. The relay, the health check, and the &lt;code&gt;ntfy-doctor&lt;/code&gt; CLI are open source: &lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-ntfy-relay&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>ntfy</category>
      <category>notifications</category>
      <category>pushnotifications</category>
    </item>
    <item>
      <title>MQTT value_template doesn't filter: our wait fired on every message</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Mon, 06 Jul 2026 16:54:56 +0000</pubDate>
      <link>https://dev.to/clarkbw--/mqtt-valuetemplate-doesnt-filter-our-wait-fired-on-every-message-2gon</link>
      <guid>https://dev.to/clarkbw--/mqtt-valuetemplate-doesnt-filter-our-wait-fired-on-every-message-2gon</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>homeassistant</category>
      <category>mqtt</category>
      <category>automation</category>
      <category>voiceassistant</category>
    </item>
    <item>
      <title>A hands-free npm publish pipeline for SignalK plugins</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:59:08 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-hands-free-npm-publish-pipeline-for-signalk-plugins-48l0</link>
      <guid>https://dev.to/clarkbw--/a-hands-free-npm-publish-pipeline-for-signalk-plugins-48l0</guid>
      <description>&lt;p&gt;Publishing a SignalK plugin to npm &lt;em&gt;once&lt;/em&gt; is easy: &lt;code&gt;npm publish&lt;/code&gt;, type your OTP,&lt;br&gt;
done. Keeping &lt;strong&gt;eight&lt;/strong&gt; of them shipping hands-free — each one auto-published on a&lt;br&gt;
tag, cross-tested on five platforms, and scoring 100 in the&lt;br&gt;
&lt;a href="https://signalk.org/signalk-plugin-registry/" rel="noopener noreferrer"&gt;SignalK plugin registry&lt;/a&gt; — is a&lt;br&gt;
different problem, and it's the one we keep seeing people stall on. This is the&lt;br&gt;
whole pipeline we run for the &lt;code&gt;@sailingnaturali/*&lt;/code&gt; plugins, end to end.&lt;/p&gt;
&lt;h2&gt;
  
  
  A pipeline is two kinds of work
&lt;/h2&gt;

&lt;p&gt;The useful lens, before any YAML: a publish pipeline is &lt;strong&gt;deterministic plumbing&lt;/strong&gt;&lt;br&gt;
plus &lt;strong&gt;judgment&lt;/strong&gt;, and they want opposite tools.&lt;/p&gt;

&lt;p&gt;The plumbing is mechanical. Given a git tag: check out, build, run the tests on&lt;br&gt;
Linux/macOS/Windows, publish with provenance, no human in the loop. The inputs are&lt;br&gt;
clean and the steps never vary. That belongs in CI — flat YAML, and &lt;strong&gt;no AI, ever&lt;/strong&gt;.&lt;br&gt;
Reaching for a model to do a job a &lt;code&gt;Makefile&lt;/code&gt; can do is how you get a slower, less&lt;br&gt;
reliable &lt;code&gt;Makefile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The other half is judgment. &lt;em&gt;Is this change worth cutting a release?&lt;/em&gt; &lt;em&gt;What actually&lt;br&gt;
goes in the notes?&lt;/em&gt; &lt;em&gt;The registry says 90 — which of the gaps matters, and is that&lt;br&gt;
&lt;code&gt;npm audit&lt;/code&gt; advisory a real ship-blocker or dev-only noise?&lt;/em&gt; &lt;em&gt;Dependabot wants to bump&lt;br&gt;
the CI pin to a "newer" tag — is that tag a trap?&lt;/em&gt; None of those reduce to a clean&lt;br&gt;
rule over clean input. You can spend a week trying to encode "is this release-worthy"&lt;br&gt;
as a deterministic check and still be wrong on the first weird case.&lt;/p&gt;

&lt;p&gt;This is the same line we draw on the boat. Deterministic code reads the NMEA bus,&lt;br&gt;
computes SignalK deltas, and fires the alarm chain — the inputs are structured and&lt;br&gt;
the logic is fixed, so an LLM there would only add latency and failure modes. But&lt;br&gt;
the moment the input is a human asking a fuzzy question, or a judgment call over&lt;br&gt;
messy state, that's where the agent earns its place. &lt;strong&gt;Drive toward deterministic&lt;br&gt;
code wherever the inputs let you; don't burn cycles forcing non-deterministic&lt;br&gt;
judgment through deterministic code.&lt;/strong&gt; Put the judgment behind a model, and make the&lt;br&gt;
seam explicit.&lt;/p&gt;

&lt;p&gt;In this pipeline the seam is a set of &lt;strong&gt;Claude Code skills&lt;/strong&gt;. The deterministic half&lt;br&gt;
lives in four workflow files. The judgment half lives in three skills that wrap those&lt;br&gt;
workflows and supply the parts a YAML file can't.&lt;/p&gt;
&lt;h2&gt;
  
  
  The judgment half: three composable skills
&lt;/h2&gt;

&lt;p&gt;We don't keep this workflow in a README that nobody re-reads. We keep it as three&lt;br&gt;
skills, each doing one job, each invokable on demand&lt;br&gt;
(&lt;a href="https://github.com/sailingnaturali/claude-skills" rel="noopener noreferrer"&gt;they're public&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;signalk-plugin&lt;/code&gt;&lt;/strong&gt; — authoring + the publish decision: the &lt;code&gt;@signalk/server-api&lt;/code&gt;
patterns that actually work, the package scaffold, when a change warrants a release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm-oidc-publish&lt;/code&gt;&lt;/strong&gt; — the OIDC trusted-publishing flow, including the new-package
chicken-and-egg that has no deterministic shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;signalk-registry&lt;/code&gt;&lt;/strong&gt; — reads the plugin's &lt;strong&gt;live&lt;/strong&gt; registry score, then judges which
locally-fixable gaps to close before the next nightly re-test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A skill is the right container precisely because each of these mixes a deterministic&lt;br&gt;
core (a command to run, a file to write) with a judgment the model has to make&lt;br&gt;
(&lt;em&gt;should we&lt;/em&gt;, &lt;em&gt;which one&lt;/em&gt;, &lt;em&gt;is this advisory real&lt;/em&gt;). The deterministic core could be a&lt;br&gt;
script; the judgment around it is why it's a skill and not a &lt;code&gt;cron&lt;/code&gt; job.&lt;/p&gt;
&lt;h2&gt;
  
  
  Scaffold (and the &lt;code&gt;files&lt;/code&gt; array that ships nothing)
&lt;/h2&gt;

&lt;p&gt;Public repo, npm package, MIT, zero runtime deps where you can manage it. The one&lt;br&gt;
that bites everyone:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signalk-&amp;lt;name&amp;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;"files"&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;"index.js"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&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;"signalk-node-server-plugin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"signalk-category-utility"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"license"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MIT"&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;If &lt;code&gt;"files"&lt;/code&gt; is missing, the package ships &lt;strong&gt;nothing&lt;/strong&gt; — your &lt;code&gt;.gitignore&lt;/code&gt; excludes the&lt;br&gt;
build output, and npm honours it. The &lt;code&gt;signalk-node-server-plugin&lt;/code&gt; keyword is what&lt;br&gt;
surfaces the package in the in-server App Store. For a TypeScript plugin, ship the&lt;br&gt;
build with &lt;code&gt;"files": ["dist"]&lt;/code&gt; and a &lt;code&gt;"prepare": "npm run build"&lt;/code&gt; so &lt;code&gt;npm publish&lt;/code&gt;&lt;br&gt;
builds first; for a scoped package add &lt;code&gt;"publishConfig": { "access": "public" }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;(One authoring trap worth its own note: serve plugin data via&lt;br&gt;
&lt;code&gt;app.registerResourceProvider&lt;/code&gt;, &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;registerWithRouter&lt;/code&gt; — the latter's&lt;br&gt;
&lt;code&gt;/plugins/&amp;lt;id&amp;gt;/*&lt;/code&gt; routes are admin-gated, so every reader would need a token. That, and&lt;br&gt;
the rest of the &lt;code&gt;@signalk/server-api&lt;/code&gt; patterns, is the &lt;code&gt;signalk-plugin&lt;/code&gt; skill's job; the&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;DSC plugin post&lt;/a&gt;&lt;br&gt;
shows it in anger.)&lt;/p&gt;
&lt;h2&gt;
  
  
  First publish: the chicken-and-egg
&lt;/h2&gt;

&lt;p&gt;This is the step that has &lt;em&gt;no&lt;/em&gt; deterministic shortcut, which is exactly why it lives in&lt;br&gt;
a skill instead of a script. npm's trusted publishing lets a GitHub Actions workflow&lt;br&gt;
&lt;code&gt;npm publish&lt;/code&gt; with &lt;strong&gt;no &lt;code&gt;NPM_TOKEN&lt;/code&gt; and no OTP&lt;/strong&gt; — it authenticates over OIDC and stamps&lt;br&gt;
provenance automatically. But you &lt;strong&gt;cannot configure a trusted publisher for a package&lt;br&gt;
that doesn't exist on npm yet&lt;/strong&gt;, and the OIDC publish needs that config to exist. So the&lt;br&gt;
&lt;em&gt;first&lt;/em&gt; publish of any new package is manual, once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm publish &lt;span class="nt"&gt;--otp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;code&amp;gt;     &lt;span class="c"&gt;# creates 0.1.0, the only time you'll type an OTP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, on npmjs.com → the package → &lt;strong&gt;Settings → Trusted Publisher → GitHub Actions&lt;/strong&gt;,&lt;br&gt;
register the repo (&lt;code&gt;owner/repo&lt;/code&gt;), the &lt;strong&gt;workflow filename&lt;/strong&gt; (&lt;code&gt;publish.yml&lt;/code&gt;), and leave&lt;br&gt;
the environment blank. From here on it's hands-free.&lt;/p&gt;

&lt;p&gt;The tell that this is misconfigured: the CI &lt;code&gt;npm publish&lt;/code&gt; fails with &lt;strong&gt;&lt;code&gt;ENEEDAUTH&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
("requires you to be logged in") while &lt;code&gt;npm ci&lt;/code&gt; and &lt;code&gt;npm test&lt;/code&gt; pass. That's never a code&lt;br&gt;
problem — it means the trusted publisher isn't registered, or the workflow filename / repo&lt;br&gt;
on npm doesn't match the one actually running.&lt;/p&gt;
&lt;h2&gt;
  
  
  OIDC: the publish workflow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/publish.yml&lt;/code&gt; — the entire deterministic half of publishing:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;publish&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- this line is what enables OIDC&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24&lt;/span&gt;      &lt;span class="c1"&gt;# npm &amp;gt;= 11.5 supports OIDC; node 24 is safe&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;             &lt;span class="c1"&gt;# needs a committed package-lock.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm publish&lt;/span&gt;        &lt;span class="c1"&gt;# no token, no --otp; provenance is automatic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha that looks like a failure and isn't: after &lt;code&gt;npm publish&lt;/code&gt; prints&lt;br&gt;
&lt;code&gt;+ &amp;lt;pkg&amp;gt;@&amp;lt;ver&amp;gt;&lt;/code&gt;, the npmjs.com &lt;strong&gt;website&lt;/strong&gt; shows the release immediately, but the&lt;br&gt;
&lt;strong&gt;registry API&lt;/strong&gt; (&lt;code&gt;npm view&lt;/code&gt;, &lt;code&gt;registry.npmjs.org/&amp;lt;pkg&amp;gt;&lt;/code&gt;) can 404 for a few minutes —&lt;br&gt;
worst for a &lt;em&gt;scoped&lt;/em&gt; package's first publish. That's propagation, not a failed publish.&lt;br&gt;
Trust the &lt;code&gt;+ &amp;lt;pkg&amp;gt;@&amp;lt;ver&amp;gt;&lt;/code&gt; line.&lt;/p&gt;
&lt;h2&gt;
  
  
  Continuous mode: the release &lt;em&gt;is&lt;/em&gt; the trigger
&lt;/h2&gt;

&lt;p&gt;With OIDC wired, shipping a new version is one deterministic command — no local build,&lt;br&gt;
no token, no OTP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh release create v0.5.2 &lt;span class="nt"&gt;--notes&lt;/span&gt; &lt;span class="s2"&gt;"Set current directions from CHS metadata"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;release: published&lt;/code&gt; event fires &lt;code&gt;publish.yml&lt;/code&gt;, which publishes over OIDC. Two&lt;br&gt;
things fall out of doing it this way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;GitHub Release doubles as the changelog&lt;/strong&gt;. The registry docks −5 for a missing
changelog, and it counts a release tagged &lt;code&gt;v&amp;lt;version&amp;gt;&lt;/code&gt; as satisfying that — so the
same &lt;code&gt;gh release create&lt;/code&gt; that publishes the package also closes the changelog gap. One
action, two boxes ticked.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Whether&lt;/em&gt; to cut the release, and what the notes say, is the judgment the
&lt;code&gt;signalk-plugin&lt;/code&gt; skill owns. The command is deterministic; the decision to run it is not.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The workflows that keep it honest
&lt;/h2&gt;

&lt;p&gt;Three more files run on every push and PR — all deterministic, all in YAML.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test.yml&lt;/code&gt; is the ordinary CI gate:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;20&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;plugin-ci.yml&lt;/code&gt; is the one most plugins skip, and it's worth real points. SignalK&lt;br&gt;
publishes a &lt;strong&gt;reusable&lt;/strong&gt; workflow that cross-tests your plugin on Linux x64/arm64,&lt;br&gt;
macOS, Windows, and armv7/Venus OS (the Cerbo). Running it earns the registry's&lt;br&gt;
plugin-ci credit — &lt;strong&gt;+10&lt;/strong&gt;, and its &lt;em&gt;absence&lt;/em&gt; is a flat &lt;strong&gt;−10&lt;/strong&gt;, which alone caps an&lt;br&gt;
otherwise-perfect plugin at 90:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SignalK Plugin CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;plugin-ci&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SignalK/signalk-server/.github/workflows/plugin-ci.yml@3645160b235364e1fd3afe1f2f6b2270b8194b7d&lt;/span&gt; &lt;span class="c1"&gt;# v2.28.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pin to a release commit SHA, never &lt;code&gt;@master&lt;/code&gt;&lt;/strong&gt; — supply-chain safety and&lt;br&gt;
reproducibility. The trailing &lt;code&gt;# v2.28.0&lt;/code&gt; comment is how a human (or Dependabot) knows&lt;br&gt;
what the SHA means.&lt;/p&gt;
&lt;h2&gt;
  
  
  Tracking updates without dragging in a dead tag
&lt;/h2&gt;

&lt;p&gt;A pinned SHA is safe but it goes stale — when signalk-server cuts a release, your pin&lt;br&gt;
silently falls behind. The deterministic fix is &lt;code&gt;dependabot.yml&lt;/code&gt;, which opens a grouped&lt;br&gt;
weekly PR to bump every action you pin, including the reusable workflow:&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;span class="na"&gt;updates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;package-ecosystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-actions&lt;/span&gt;
    &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weekly&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;github-actions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;patterns&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;*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# signalk-server carries a stray ancient v6.6.6 tag (a 2019 commit with no&lt;/span&gt;
      &lt;span class="c1"&gt;# plugin-ci.yml) that outranks the real 2.x release line by semver, so&lt;/span&gt;
      &lt;span class="c1"&gt;# dependabot "upgrades" the reusable-workflow pin to a dead ref and the&lt;/span&gt;
      &lt;span class="c1"&gt;# workflow fails to load (0s failure). Ignore majors: the real releases are&lt;/span&gt;
      &lt;span class="c1"&gt;# 2.x minors, which dependabot still tracks; a genuine 3.0 we bump by hand.&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dependency-name&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/signalk-server*"&lt;/span&gt;
        &lt;span class="na"&gt;update-types&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;version-update:semver-major"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;ignore&lt;/code&gt; block is the whole point of this section. signalk-server carries a stray&lt;br&gt;
&lt;strong&gt;&lt;code&gt;v6.6.6&lt;/code&gt;&lt;/strong&gt; tag — a 2019 commit, no &lt;code&gt;plugin-ci.yml&lt;/code&gt; in it — that outranks the real&lt;br&gt;
&lt;code&gt;2.x&lt;/code&gt; release line by raw semver. Left alone, Dependabot helpfully "upgrades" your pin to&lt;br&gt;
that dead ref, the reusable workflow can't be loaded, and CI fails in 0 seconds. Ignoring&lt;br&gt;
the &lt;em&gt;major&lt;/em&gt; bump keeps Dependabot tracking the real &lt;code&gt;2.x&lt;/code&gt; minors while refusing the trap;&lt;br&gt;
a genuine &lt;code&gt;3.0&lt;/code&gt; is rare enough to bump by hand. This is the kind of failure mode that's&lt;br&gt;
obvious in hindsight and invisible up front — exactly what you want captured in a config&lt;br&gt;
comment so nobody rediscovers it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The score is a checkable bar, not a vibe
&lt;/h2&gt;

&lt;p&gt;All of the above exists to hit one number. The registry scores every plugin 0–100 and&lt;br&gt;
re-tests nightly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Installs / Loads / Activates / Schema&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;Registry harness (live server)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests pass&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;Registry harness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security audit&lt;/td&gt;
&lt;td&gt;0–20&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changelog&lt;/td&gt;
&lt;td&gt;−5 if missing&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;−5 if missing&lt;/td&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugin-CI&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−10 if missing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Local (the workflow file)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 80 harness points run against a live SignalK server — you can't reproduce them&lt;br&gt;
locally, but the registry serves each plugin's result as JSON, so you can &lt;em&gt;read&lt;/em&gt; them.&lt;br&gt;
The rest you fix in the repo before the next nightly. Without &lt;code&gt;plugin-ci.yml&lt;/code&gt; the ceiling&lt;br&gt;
is 90, not 100; that surprises people who've done everything else right.&lt;/p&gt;

&lt;p&gt;This is where the &lt;code&gt;signalk-registry&lt;/code&gt; skill closes the loop, and where the deterministic /&lt;br&gt;
judgment split shows up one last time. Reading the published score is deterministic — it's&lt;br&gt;
an HTTP GET and some JSON:&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;PKG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"require('./package.json').name"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SLUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PKG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^@//; s#/#__#'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# @scope/name -&amp;gt; scope__name&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://signalk.org/signalk-plugin-registry/plugins/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to &lt;em&gt;do&lt;/em&gt; with it is the judgment: is that lone &lt;code&gt;moderate&lt;/code&gt; advisory dev-only noise&lt;br&gt;
(esbuild, vitest — never shipped) or a real ship-blocker? Is the pin behind the latest&lt;br&gt;
release, and does that release even contain &lt;code&gt;plugin-ci.yml&lt;/code&gt; at the new ref? Those are the&lt;br&gt;
calls the skill makes, so "publish to 100" becomes something you can actually check on&lt;br&gt;
demand instead of a number you hope is still true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gh release create vX.Y.Z ─→ publish.yml ─(OIDC, no token)─→ npm  + provenance
                          └─ Release = changelog (−5 gap closed)

push / PR ─→ test.yml          (build + unit tests)
          └─ plugin-ci.yml     (5-platform reusable CI, +10 / −10, pinned SHA)

weekly ───→ dependabot.yml     (bumps the pin; ignores the v6.6.6 trap)

on demand ─→ signalk-registry skill  (reads live score, judges the gaps)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four deterministic YAML files do the plumbing; three skills supply the judgment the&lt;br&gt;
YAML can't. That's the same bet we make everywhere on this stack — an all-electric&lt;br&gt;
charter catamaran whose ops run on exactly this division of labour: deterministic code&lt;br&gt;
where the inputs are clean, a model where they aren't, and a clear seam between the two.&lt;br&gt;
The skills are &lt;a href="https://github.com/sailingnaturali/claude-skills" rel="noopener noreferrer"&gt;public&lt;/a&gt;; a plugin that&lt;br&gt;
runs the whole pipeline is &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>npm</category>
      <category>oidc</category>
      <category>trustedpublishing</category>
    </item>
    <item>
      <title>When MCP tools break, isolate the server before blaming the agent</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:03:08 +0000</pubDate>
      <link>https://dev.to/clarkbw--/when-mcp-tools-break-isolate-the-server-before-blaming-the-agent-30bd</link>
      <guid>https://dev.to/clarkbw--/when-mcp-tools-break-isolate-the-server-before-blaming-the-agent-30bd</guid>
      <description>&lt;p&gt;You add a new MCP server, or a new tool to an existing one. You ask the agent to use it and it says the tool isn't available — or worse, it confidently reports that "only the HTTP server is connected" and the new stdio servers aren't live. The instinct is to blame the MCP server or bounce the runtime. Resist it. The agent's narration about its own toolset is not ground truth, and a malformed test invocation will happily manufacture a fake "MCP unreachable" symptom. Here's the broke → tried → fixed of a debugging session that burned time on exactly this.&lt;/p&gt;

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

&lt;p&gt;We'd just added a second stdio MCP server and a new tool. To smoke-test it, we hand-typed a quick probe at the agent runtime and asked it to use the new capability. The agent came back with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Those tools aren't in my live toolset right now — only the
Home Assistant (HTTP/SSE) server is connected. I'll read the
files directly instead.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then shelled out to read files off disk to answer the question — being resourceful, which made it &lt;em&gt;look&lt;/em&gt; like the tools genuinely weren't wired up. Two new stdio servers, both apparently invisible to the session, while the one HTTP/SSE server was fine. That sure reads like the stdio MCP servers aren't connecting.&lt;/p&gt;

&lt;p&gt;That self-report is the trap. Treating "the agent says the tools aren't available" as a diagnosis — instead of a symptom — is what sent us down the wrong path.&lt;/p&gt;

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

&lt;p&gt;There are two failure modes here and they look identical from the agent's chair:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The MCP server actually isn't connecting&lt;/strong&gt; (bad command path, crash on startup, transport mismatch).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session never loaded the toolset&lt;/strong&gt;, because the way you invoked the agent doesn't load tools the way the production path does — wrong flags, wrong profile/skill selector, a different transport. The server is fine; your &lt;em&gt;probe&lt;/em&gt; is malformed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The agent can't tell these apart for you, and it will narrate either one as "tools not available." Worse, an LLM describing its own capabilities is itself a confabulation risk — it pattern-completes a plausible story ("only the HTTP server is live") around the fact that some tools didn't show up. That story is not evidence about server health.&lt;/p&gt;

&lt;p&gt;So the discipline is: &lt;strong&gt;don't ask the agent whether the server is up. Ask the server.&lt;/strong&gt; Isolate the layer before you touch anything.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Dead-end 1 — trusted the agent's "tools unavailable"
&lt;/h3&gt;

&lt;p&gt;We took the self-report at face value. The agent said the stdio servers weren't connected, so we assumed they weren't connected. This is the root mistake every later dead-end inherits from — we let the agent's narration stand in for a connectivity check. It is not one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead-end 2 — restarted the long-running gateway daemon
&lt;/h3&gt;

&lt;p&gt;"Tools didn't load? Refresh them — restart the runtime." So we bounced the long-running agent gateway daemon, the shape of which is:&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;# generic shape — restart the long-running agent runtime/gateway&lt;/span&gt;
&amp;lt;runtime&amp;gt; gateway restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then re-ran the same probe. No change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Still only the Home Assistant server is connected — the other
tools aren't in my toolset.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restarting a daemon to "refresh tools" is a guess, not a diagnosis. If the server were genuinely stale you'd want evidence of that &lt;em&gt;first&lt;/em&gt;. We had none — we were rebooting on a hunch, and the hunch was wrong. (It also cost real time: a gateway restart drops every in-flight session.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Dead-end 3 — re-probed ad-hoc, still "unavailable"
&lt;/h3&gt;

&lt;p&gt;We re-ran the hand-typed probe a few more times, tweaking the wording, still getting "tools not available." At this point it looks airtight: server's been restarted, agent still can't see the tools, therefore the server must be broken. Every signal pointed at the MCP server — and every signal was coming through the same malformed probe, so they were all the same false negative wearing different hats.&lt;/p&gt;

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

&lt;p&gt;Stop reasoning through the agent. &lt;strong&gt;Isolate the layer&lt;/strong&gt;, then reproduce the &lt;em&gt;exact&lt;/em&gt; production invocation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — test the MCP server standalone
&lt;/h3&gt;

&lt;p&gt;Spawn the server fresh, outside any agent session, and ask it to enumerate its tools. Most runtimes ship a connectivity check that does exactly this; the vendor-neutral tool is the &lt;a href="https://modelcontextprotocol.io/docs/tools/inspector" rel="noopener noreferrer"&gt;MCP Inspector&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your runtime has a built-in check (generic shape — it spawns the server fresh, bypassing the session):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&amp;lt;runtime&amp;gt; mcp &lt;span class="nb"&gt;test&lt;/span&gt; &amp;lt;server&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or talk to the server directly with the MCP Inspector — no runtime involved at all. The CLI mode is perfect for a connectivity assertion:&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;# list the tools a stdio server actually exposes, runtime out of the picture&lt;/span&gt;
npx @modelcontextprotocol/inspector &lt;span class="nt"&gt;--cli&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;your-server-launch-command&amp;gt; &lt;span class="nt"&gt;--method&lt;/span&gt; tools/list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;npx @modelcontextprotocol/inspector&lt;/code&gt; with no &lt;code&gt;--cli&lt;/code&gt; opens the browser UI at &lt;code&gt;http://localhost:6274&lt;/code&gt; — connect, open the &lt;strong&gt;Tools&lt;/strong&gt; tab, click &lt;strong&gt;List Tools&lt;/strong&gt;.)&lt;/p&gt;

&lt;p&gt;The output settles it immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connected.
server: signalk          → 7 tools discovered: read_sensor, battery_state,
                            get_route, get_active_alarms, ...
server: vessel_knowledge → 4 tools discovered: find_equipment, get_equipment, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both servers connect and enumerate every tool. &lt;strong&gt;The servers were fine the entire time&lt;/strong&gt; — through all three dead-ends. That one command would have saved the gateway restart and every ad-hoc re-probe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — reproduce with the exact production invocation
&lt;/h3&gt;

&lt;p&gt;If the server lists its tools standalone, the bug (if any) is in &lt;em&gt;how the session loads them&lt;/em&gt;, not the server. So stop hand-typing variants and run the agent the way production runs it — same skill/profile selector, same flags, same transport.&lt;/p&gt;

&lt;p&gt;Our production path (a voice/MQTT bridge) launches the agent in oneshot mode with a &lt;strong&gt;namespaced skill selector&lt;/strong&gt; and the query passed via the query flag. The malformed probe had used a bare, non-namespaced selector and the wrong flag — which loads a different toolset (or none of the stdio toolsets). The shape that matters:&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;# WRONG — ad-hoc probe: bare/non-namespaced selector, wrong flag.&lt;/span&gt;
&lt;span class="c"&gt;# Loads a different profile; stdio toolsets never attach.&lt;/span&gt;
&amp;lt;runtime&amp;gt; chat &lt;span class="nt"&gt;-m&lt;/span&gt; &amp;lt;model&amp;gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"use the new tool"&lt;/span&gt;

&lt;span class="c"&gt;# RIGHT — the exact production invocation: namespaced skill selector&lt;/span&gt;
&lt;span class="c"&gt;# (profile/&amp;lt;skill&amp;gt;) + the real query flag, oneshot.&lt;/span&gt;
&amp;lt;runtime&amp;gt; chat &lt;span class="nt"&gt;-Q&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; profile/&amp;lt;skill&amp;gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"use the new tool"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the right one and the agent calls the tools on the first try — no restart, no file-reading workaround:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[tool] get_active_alarms → none active
[tool] find_equipment("Bellmarine") → bellmarine-ddw-10
... rated temperature zones: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was a one-line change to the invocation. The server never moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The one rule that would have short-circuited all of it:&lt;/strong&gt; don't restart a daemon to "refresh tools" before the server is &lt;em&gt;proven&lt;/em&gt; stale. Prove it with a standalone &lt;code&gt;mcp test&lt;/code&gt; / Inspector run first.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent's self-report about its own toolset is not ground truth.&lt;/strong&gt; An LLM narrating "only X is connected" is pattern-completing a story around missing tools, not reading a connection table. Treat it as a symptom to investigate, never as a diagnosis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A malformed probe fakes "MCP unreachable."&lt;/strong&gt; Wrong flags, a non-namespaced skill/profile selector, or a different transport will load none of the tools you expect — and produce the &lt;em&gt;exact&lt;/em&gt; symptom of a dead server. If you debug through an ad-hoc invocation, you can chase a server that was never broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stdio vs SSE/HTTP transports attach differently.&lt;/strong&gt; It's common to see the HTTP/SSE server present while stdio servers are absent in a session — that asymmetry usually means the session/launch path, not the stdio servers. (See the &lt;a href="https://forum.cursor.com/t/mcp-server-connected-green-dot-and-tools-discovered-in-logs-but-0-tools-in-ui-and-agent/160620" rel="noopener noreferrer"&gt;Cursor forum&lt;/a&gt; threads where a server is "connected, tools discovered in logs" but shows 0 tools to the agent — same shape.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarting the runtime is a guess, not a diagnosis.&lt;/strong&gt; It feels productive and occasionally papers over the real issue, which is worse — it teaches you the wrong fix. Isolate first; the standalone server check is two commands and removes an entire branch of the search tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This applies to any MCP host&lt;/strong&gt; — Claude Desktop, Cursor, IDE integrations, custom agents. The Inspector talks to your server with zero host involved, which is precisely why it's the right first probe: it can't be fooled by the host's session quirks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The order is the whole lesson: &lt;strong&gt;server (standalone) → invocation (exact production form) → only then the runtime.&lt;/strong&gt; Don't bounce the daemon until the first two are ruled out.&lt;/p&gt;

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

&lt;p&gt;This came out of wiring MCP servers into a ship's-computer agent for an all-electric charter catamaran — SignalK, vessel-knowledge, and tide/weather tools behind a local LLM. When the agent said it couldn't see them, the servers had been fine all along; the probe was wrong. The MCP servers behind it 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;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>agents</category>
      <category>debugging</category>
    </item>
    <item>
      <title>How LLM agents confabulate infrastructure and data provenance</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 12:03:04 +0000</pubDate>
      <link>https://dev.to/clarkbw--/how-llm-agents-confabulate-infrastructure-and-data-provenance-eac</link>
      <guid>https://dev.to/clarkbw--/how-llm-agents-confabulate-infrastructure-and-data-provenance-eac</guid>
      <description>&lt;p&gt;Give an agent a tool that returns a number and ask it a question, and it will often answer with the number &lt;em&gt;plus&lt;/em&gt; a story: which machine is running the service, whether that service is healthy, whether the reading is "live" or "from a test rig," whether the thing being measured is on or offline. None of the story came from a tool. The model made it up — confidently, in the same flat voice it uses for the parts it actually observed.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://en.wikipedia.org/wiki/Hallucination_(artificial_intelligence)" rel="noopener noreferrer"&gt;confabulation, not hallucination&lt;/a&gt;: the model isn't inventing something unrelated to reality, it's filling an &lt;em&gt;unobservable gap&lt;/em&gt; with the most plausible continuation and stating it as fact. The distinction matters because the fix is different. You don't fix confabulation by telling the model to be more accurate; you fix it by removing the gap, or by removing the model's permission to narrate into it.&lt;/p&gt;

&lt;p&gt;We hit two distinct flavors of this building a local-LLM agent (an 8B running the ship's-computer role on a boat). Same root cause, same three-part fix. This is what each looked like and what actually shut them down.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Flavor 1 — inventing infrastructure it can't observe
&lt;/h3&gt;

&lt;p&gt;A cosmetic, auxiliary error scrolled past — a throwaway session-title-generation call timed out. That's a side feature; it says nothing about whether the main conversation works. But the agent picked it up as a thread to pull, and produced a diagnosis like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;The &lt;span class="nb"&gt;local &lt;/span&gt;model backend is down. Ollama runs as a Docker container on the
dashboard host&lt;span class="p"&gt;;&lt;/span&gt; that container has crashed. Confirming now.

&lt;span class="nv"&gt;$ &lt;/span&gt;docker ps &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="nv"&gt;ancestor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ollama/ollama
&lt;span class="o"&gt;(&lt;/span&gt;no output&lt;span class="o"&gt;)&lt;/span&gt;

Confirmed — no Ollama container is running on the dashboard host. That&lt;span class="s1"&gt;'s the
outage.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every operational claim in there is fabricated. The model backend was healthy and reachable the whole time. Ollama wasn't a container on the dashboard host — it ran on a &lt;em&gt;different&lt;/em&gt; machine entirely. And the &lt;code&gt;docker ps&lt;/code&gt; it ran to "confirm" executed on its &lt;strong&gt;own&lt;/strong&gt; host, not the host it was talking about — so the empty output was empty for an irrelevant reason, and the model read that emptiness as proof of its invented topology.&lt;/p&gt;

&lt;p&gt;The agent had no tool that could observe the network layout. So it invented one that sounded right, then mined whatever command output it &lt;em&gt;could&lt;/em&gt; get for confirmation — even output from the wrong box.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flavor 2 — narrating data provenance it was never told
&lt;/h3&gt;

&lt;p&gt;Simpler to reproduce, and creepier. Ask for a sensor reading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; how's our depth?

Depth 4.2 meters below the keel. This reading is coming from the local
test/mock rig — not from the vessel, since we're currently ashore.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The depth number is real (a tool returned it). Everything after the period is confabulated. "Test/mock rig," "ashore" — none of those words exist in any prompt, any config, or any field the tool returned. The model volunteered an entire provenance story for a bare number, in the same confident register as the reading itself.&lt;/p&gt;

&lt;p&gt;If you're searching for this: the agent &lt;strong&gt;makes up the data source&lt;/strong&gt;, appends &lt;code&gt;(this is live data from the real sensor)&lt;/code&gt; or &lt;code&gt;the device is currently offline&lt;/code&gt; or &lt;code&gt;this is a test value&lt;/code&gt;, attributes a reading to a system nobody mentioned. That's provenance confabulation.&lt;/p&gt;

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

&lt;p&gt;Both flavors are the same mechanism: &lt;strong&gt;the model treats an unobservable gap as something to narrate.&lt;/strong&gt; Where it has no tool to see the truth, it predicts the most plausible truth and states it with the same confidence as the parts it observed. The model has no internal marker for "I observed this" vs "I generated this" — to the next-token predictor, both are just tokens to continue.&lt;/p&gt;

&lt;p&gt;Two things made it worse, and they're worth separating:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smaller models do this more.&lt;/strong&gt; An 8B pattern-completes harder and self-censors less than a frontier model. The infra-diagnosis case ran on a larger local reasoning model and &lt;em&gt;still&lt;/em&gt; did it — so it's not purely a size thing — but the bare-number-gets-a-backstory behavior was squarely an 8B habit. If you're running local models, budget for more of this, not less.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We seeded it.&lt;/strong&gt; This is the part that stings. The persona prompt literally contained the words &lt;em&gt;"mock data"&lt;/em&gt; — buried in an unrelated clause about how stored records handle timekeeping. The model picked that token up and amplified it into "the mock rig." &lt;strong&gt;Your own prompt is training data for the next token.&lt;/strong&gt; A stray word you put in the context, even in a caution or an aside, is a word the model can and will run with. We handed it "mock" and it handed us back a confident sentence about a mock rig.&lt;/p&gt;

&lt;p&gt;That reframes the whole problem. Confabulation into a gap is one thing; &lt;em&gt;amplifying a word you yourself planted&lt;/em&gt; is a self-inflicted version of it. Which is exactly why the most intuitive fixes make it worse.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Attempt 1 — warn the model about the gap
&lt;/h3&gt;

&lt;p&gt;The honest instinct: the data sometimes comes from a test rig, so &lt;em&gt;tell&lt;/em&gt; the model that, so it can be careful about it.&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;# in the persona prompt&lt;/span&gt;
Note: sensor data may currently be sourced from a local test rig rather than
the live vessel. Keep this in mind.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result — the model now announces the test rig &lt;em&gt;every time&lt;/em&gt;, unprompted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; how's our depth?

Depth 4.2 meters. Note that this is sourced from the local test rig rather
than the live vessel.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We told it to "keep in mind" a fact and it kept it in mind by &lt;em&gt;saying it out loud, as fact, constantly.&lt;/em&gt; A caution in the prompt is not a private note to the model. It's text the model will surface. We'd taken a word it was occasionally confabulating and promoted it to a word it now states on every reply. Strictly worse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2 — tell it not to hallucinate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# in the persona prompt&lt;/span&gt;
Do not hallucinate. Do not make up information. Be accurate and factual.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Too vague to bind to anything. "Don't hallucinate" gives the model no operational handle — it doesn't &lt;em&gt;experience&lt;/em&gt; the confabulated provenance as a hallucination; from the inside, "this is from the test rig" is just a plausible, on-topic continuation. The instruction sailed right past the behavior and the agent kept narrating provenance and inventing topology. Generic anti-hallucination boilerplate does nothing against confident, on-distribution confabulation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3 — list the right facts so it stops guessing wrong
&lt;/h3&gt;

&lt;p&gt;For the infra case, we tried correcting the topology inline: tell it which host runs what so it stops inventing.&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;# in the diagnosis prompt&lt;/span&gt;
The model backend runs on the compute host, not the dashboard host.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This &lt;em&gt;reduced&lt;/em&gt; the specific wrong guess but didn't fix the mechanism — the model would still run a diagnostic command on the wrong host and reverse-justify from its output, because it had no way to know &lt;em&gt;which host its own shell was answering for&lt;/em&gt;. The shell tool ran local while the model reasoned about a remote box, and silently manufactured false evidence. You can correct one confabulation by hand, but the next gap is still a gap.&lt;/p&gt;

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

&lt;p&gt;Three layers, because no single one holds on a small model.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Ground the real, observable facts — so there's no gap to fill
&lt;/h3&gt;

&lt;p&gt;For the infra flavor: the model invents topology because it can't see topology. So put the &lt;em&gt;true&lt;/em&gt; topology in the context, and — more importantly — make tools state their own vantage point. A shell tool that runs on host A while the agent reasons about host B must say so, or the agent must be told it cannot observe B from where it sits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are running on the compute host. Any shell command you run answers for
THIS host only. You cannot observe other hosts from here. If a question is
about another machine, say "I can't observe that host from here" — do not
infer its state from local output.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That converts "invent a plausible answer" into "state a boundary." The gap is named instead of filled.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. An explicit anti-provenance rule in the always-on persona
&lt;/h3&gt;

&lt;p&gt;For the provenance flavor, the generic "don't hallucinate" failed because it wasn't specific. So name the exact behavior and forbid it — in the &lt;strong&gt;always-on&lt;/strong&gt; persona, not a conditional skill file, because it has to apply to every query. These are the actual lines from our shared persona (&lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;&lt;code&gt;SOUL.md&lt;/code&gt;&lt;/a&gt;):&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="gu"&gt;## Avoid&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Speculating about data provenance. Report the reading, and its SignalK path
  if asked. Do not narrate whether data is "live," from the "real vessel," a
  "test rig," a "mock," or whether the vessel is "ashore," "hauled out," or
  "underway" — you are not given that context and must not guess it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the default that backs it, so "I don't know" is the sanctioned move instead of a guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Confabulation under uncertainty. "I don't have that" beats a plausible guess.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win over Attempt 2 is specificity. "Don't hallucinate" gives the model nothing to grab. "Don't say 'live'/'mock'/'test rig'/'ashore'; report the reading and its path if asked" names the exact tokens and the exact allowed alternative. The model can act on that.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Audit the prompt for words you don't want spoken — and remove them
&lt;/h3&gt;

&lt;p&gt;This is the one almost nobody does. Grep your own prompt for any term that names something you don't want stated as fact, and &lt;strong&gt;take it out.&lt;/strong&gt; We had the literal string &lt;code&gt;mock data&lt;/code&gt; in the persona (in an unrelated timekeeping clause). We reworded it out. You cannot have a rule that says "never say 'mock'" while the word "mock" sits in the context — you're seeding and forbidding the same token, and on a small model the seed wins.&lt;/p&gt;

&lt;p&gt;The meta-rule: &lt;strong&gt;don't put a word in the context you don't want amplified back at you.&lt;/strong&gt; Not in an instruction, not in a caution, not in an aside. If it's in the prompt, treat it as something the model might say out loud.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Belt and suspenders — guard in the tool layer
&lt;/h3&gt;

&lt;p&gt;Prompt rules reduce this on an 8B; they don't eliminate it. The deterministic backstop is to have the tool return &lt;em&gt;only what should be stated&lt;/em&gt;, so there's no raw provenance field for the model to narrate even if it's tempted. The reading goes out as a clean, pre-formatted value with no &lt;code&gt;source: "mock"&lt;/code&gt;, no &lt;code&gt;host:&lt;/code&gt;, no &lt;code&gt;is_live:&lt;/code&gt; for the model to read and run with:&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;// what the tool returns — nothing to narrate into&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;4.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;"4.2 meters below the keel"&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="c1"&gt;// NOT: { "value": 4.2, "source": "mock-rig", "host": "...", "is_live": false }&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the model never sees a provenance field, the prompt rule has far less to fight. Robustness lives in the tool; the prompt rule is the second line, not the only one.&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;Confabulation wears the same voice as truth.&lt;/strong&gt; The invented topology and the real reading arrive in identical confident prose. There's no in-band signal that one half was observed and the other generated — which is exactly why you can't trust an agent's infra &lt;em&gt;diagnosis&lt;/em&gt; as a finding. Treat it as a hypothesis until a deterministic check from the right vantage point confirms it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A caution in the prompt is a public statement, not a private note.&lt;/strong&gt; This is the counterintuitive one. Telling the model "this might be a test rig, be careful" doesn't make it careful — it makes it &lt;em&gt;announce the test rig&lt;/em&gt;. If you wouldn't want the sentence in the output, don't put its keywords in the input.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Distinguish auxiliary failures from real ones before you let the agent run with them.&lt;/strong&gt; Half of the infra confabulation was the agent treating a cosmetic timeout (a title-generation side call) as a pipeline outage. A timed-out summary/embedding/title call is not an outage; check whether the primary path actually failed before escalating.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Guardrails reduce, they don't eliminate — pair the prompt with the tool layer.&lt;/strong&gt; On a small model, expect residual confabulation even with a clean prompt and an explicit rule. The tool returning only speakable fields is what makes it deterministic. Same theme as fixing formatting in the tool layer rather than the prompt: anything that &lt;em&gt;must&lt;/em&gt; hold belongs below the model, not in instructions to it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The general lesson is one sentence:&lt;/strong&gt; confabulation is the model narrating an unobservable gap, so the fix is to remove the gap (ground the facts), remove the permission (an explicit, specific rule), and remove the temptation (don't seed the word, and don't hand the model a field it shouldn't speak).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This came out of running a local-LLM ship's-computer agent on an all-electric charter catamaran, where a confident wrong sentence about where a depth reading "came from" is worse than no sentence at all. The persona and the MCP tool servers behind it are open source: &lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;github.com/sailingnaturali/naturali-agents&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>An MCP 404 trips the circuit breaker and drops valid reads</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 08:35:22 +0000</pubDate>
      <link>https://dev.to/clarkbw--/an-mcp-404-trips-the-circuit-breaker-and-drops-valid-reads-37gb</link>
      <guid>https://dev.to/clarkbw--/an-mcp-404-trips-the-circuit-breaker-and-drops-valid-reads-37gb</guid>
      <description>&lt;p&gt;An agent intermittently "loses" a tool. It reads a sensor fine ten times, then&lt;br&gt;
flatly claims the value is unavailable — for a path you can see returning data in&lt;br&gt;
the upstream server's own UI. Restart the session and it works again, for a&lt;br&gt;
while. The cause isn't the tool and isn't the server: it's a &lt;code&gt;raise_for_status()&lt;/code&gt;&lt;br&gt;
in the HTTP client turning a perfectly normal &lt;strong&gt;404 ("not published")&lt;/strong&gt; into a&lt;br&gt;
tool &lt;em&gt;failure&lt;/em&gt;, and a burst of those tripping the agent runtime's&lt;br&gt;
&lt;strong&gt;consecutive-failure circuit breaker&lt;/strong&gt; — which then suppresses the &lt;em&gt;valid&lt;/em&gt; calls&lt;br&gt;
queued behind them.&lt;/p&gt;

&lt;p&gt;This is the broke → tried → fixed of an MCP tool that wraps an HTTP API where&lt;br&gt;
"absent" is a legitimate answer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;The stack: a small local model asks a question, the agent runtime fans it out&lt;br&gt;
into several tool calls, each tool call hits an MCP server, and the MCP server&lt;br&gt;
reads an upstream HTTP API. In our case the upstream is &lt;a href="https://signalk.org/" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt;&lt;br&gt;
(a marine data server) and the tool is &lt;code&gt;read_sensor(path)&lt;/code&gt; — but nothing here is&lt;br&gt;
marine-specific. Substitute any HTTP API where a missing key returns 404.&lt;/p&gt;

&lt;p&gt;The symptom, from the agent transcript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user: how's our course and speed?
agent: Speed over ground is 6.1 knots. Course is unavailable right now.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Course was &lt;em&gt;not&lt;/em&gt; unavailable. The upstream server was serving&lt;br&gt;
&lt;code&gt;navigation.courseOverGroundTrue&lt;/code&gt; = 205° the entire time — you can watch it tick&lt;br&gt;
in the server's own data browser. Yet the agent says "unavailable," and a minute&lt;br&gt;
later the same question answers correctly. Intermittent, path-specific,&lt;br&gt;
self-healing. The worst kind.&lt;/p&gt;

&lt;p&gt;The naive client — the one almost everyone writes first — looks like this:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/vessels/self/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# &amp;lt;-- every non-2xx becomes an exception
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;raise_for_status()&lt;/code&gt; raises &lt;code&gt;HTTPStatusError&lt;/code&gt; on &lt;strong&gt;any&lt;/strong&gt; 4xx/5xx. The MCP tool&lt;br&gt;
that calls &lt;code&gt;get_value&lt;/code&gt; lets that exception propagate, so the runtime records the&lt;br&gt;
tool call as a failure. That seems reasonable — until you look at &lt;em&gt;what&lt;/em&gt; is&lt;br&gt;
404ing and &lt;em&gt;how often&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;Two facts collide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fact 1: a 404 here means "this path isn't published," which is normal.&lt;/strong&gt; A&lt;br&gt;
boat with no electronic compass doesn't publish &lt;code&gt;navigation.headingMagnetic&lt;/code&gt;. A&lt;br&gt;
vessel at anchor doesn't publish &lt;code&gt;navigation.courseOverGroundTrue&lt;/code&gt; (no course&lt;br&gt;
when you're not moving). The server's REST API answers those with a 404. That's&lt;br&gt;
not an error condition — it's the API's way of saying &lt;em&gt;I don't have that&lt;/em&gt;. The&lt;br&gt;
same is true of countless HTTP APIs: 404 on a resource that doesn't exist is an&lt;br&gt;
expected, information-bearing answer, not a fault.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fact 2: the agent generates 404s in bursts, by design.&lt;/strong&gt; Ask a small model&lt;br&gt;
(this bit us on a local ~30B) "course and speed?" and it doesn't know the exact&lt;br&gt;
paths, so it &lt;em&gt;guesses&lt;/em&gt; — fanning out parallel calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read_sensor("navigation.headingTrue")        -&amp;gt; 404  (no compass)
read_sensor("navigation.headingMagnetic")    -&amp;gt; 404  (no compass)
read_sensor("sensors.depth")                 -&amp;gt; 404  (wrong namespace)
read_sensor("navigation.courseOverGroundTrue") -&amp;gt; 200, 205°   &amp;lt;-- the good one
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three guesses 404, then the &lt;em&gt;correct&lt;/em&gt; path is queued right behind them. With the&lt;br&gt;
naive client, those three 404s are three raised exceptions — three recorded&lt;br&gt;
&lt;strong&gt;tool failures in a row&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now bring in the runtime. Agent runtimes run a per-tool &lt;strong&gt;circuit breaker&lt;/strong&gt;: after&lt;br&gt;
N consecutive failures of the same tool, the breaker &lt;em&gt;opens&lt;/em&gt; and the runtime&lt;br&gt;
stops dispatching that tool for a cooldown, returning an immediate&lt;br&gt;
"tool unavailable" instead of calling it. The widely-cited default is &lt;strong&gt;N = 3&lt;/strong&gt;&lt;br&gt;
(&lt;a href="https://github.com/openclaw/openclaw/issues/67399" rel="noopener noreferrer"&gt;circuit breaker for repeated tool failures in agent loops&lt;/a&gt;;&lt;br&gt;
&lt;a href="https://dev.to/neurolink/mcp-circuit-breaker-preventing-cascading-failures-in-ai-tool-calls-4bi4"&gt;MCP circuit breaker pattern&lt;/a&gt;).&lt;br&gt;
Ours was exactly 3.&lt;/p&gt;

&lt;p&gt;So: three guessed-path 404s trip the breaker, the breaker opens, and the &lt;em&gt;valid&lt;/em&gt;&lt;br&gt;
&lt;code&gt;courseOverGroundTrue&lt;/code&gt; read queued behind them never gets dispatched. The agent&lt;br&gt;
is handed "tool unavailable" for a path that was serving data the whole time. The&lt;br&gt;
model's bad guessing is the &lt;em&gt;trigger&lt;/em&gt;, but &lt;code&gt;raise_for_status()&lt;/code&gt; on a 404 is what&lt;br&gt;
makes it &lt;em&gt;fatal&lt;/em&gt; instead of harmless.&lt;/p&gt;

&lt;p&gt;It even masquerades as a server crash. In the upstream access log, the server&lt;br&gt;
happily answers requests up to the last 404 — and then sees &lt;em&gt;nothing&lt;/em&gt;. The later&lt;br&gt;
requests never left the client, because the breaker opened on the client side.&lt;br&gt;
Don't go chasing a phantom upstream outage; check the runtime's failure counter.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Attempt 1 — raise on every non-2xx (the starting point)
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# raises HTTPStatusError on 404 just like on 500
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This is the bug. &lt;code&gt;raise_for_status()&lt;/code&gt; makes no distinction between "the resource&lt;br&gt;
isn't there" (404) and "the server is broken" (500). Every guessed path becomes a&lt;br&gt;
tool failure. Three in a row → breaker open → valid reads dropped. The contract is&lt;br&gt;
wrong at the source: we're reporting &lt;em&gt;absence&lt;/em&gt; as &lt;em&gt;failure&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 2 — just bump the breaker threshold
&lt;/h3&gt;

&lt;p&gt;If 3 is too few, raise 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 runtime config&lt;/span&gt;
&lt;span class="na"&gt;tool_failures&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;same_tool_failure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;    &lt;span class="c1"&gt;# was 3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This doesn't fix anything — it raises the price of admission. A chattier model, or&lt;br&gt;
a question that fans out into more guesses, still walks past 8. Worse, you've now&lt;br&gt;
&lt;em&gt;blunted the breaker for real outages&lt;/em&gt;: when the upstream genuinely goes down, the&lt;br&gt;
runtime now eats 8 failed calls before protecting itself. You've traded a&lt;br&gt;
false-positive problem for a slower true-positive. The breaker isn't the thing&lt;br&gt;
that's wrong; the failure &lt;em&gt;classification&lt;/em&gt; feeding it is.&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 3 — retry the failed calls
&lt;/h3&gt;

&lt;p&gt;Wrap the call in a retry so a "transient" failure gets another shot:&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&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="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http&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="n"&gt;url&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;400&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 404 is not transient. The path that isn't published at 0 ms is still not&lt;br&gt;
published at 600 ms. All this does is turn three fast 404s into nine slow ones —&lt;br&gt;
&lt;em&gt;more&lt;/em&gt; failures toward the breaker, plus latency, and the breaker still opens.&lt;br&gt;
Retrying is the right tool for timeouts and 5xx; it's the wrong tool for "the&lt;br&gt;
resource doesn't exist."&lt;/p&gt;
&lt;h3&gt;
  
  
  Attempt 4 — fix it in the prompt
&lt;/h3&gt;

&lt;p&gt;Tell the model the exact paths so it stops guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;When asked for course, read navigation.courseOverGroundTrue.
When asked for heading, read navigation.headingTrue.
Never read sensors.depth; use environment.depth.belowTransducer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Path hints &lt;em&gt;reduce&lt;/em&gt; guessing, and they're worth having — but they're&lt;br&gt;
model-dependent and fragile, and they don't make the tool layer safe. A different&lt;br&gt;
model, a reworded question, a path the hint list forgot, and you're back to a&lt;br&gt;
404 burst tripping the breaker. Breaker-safety has to live in the deterministic&lt;br&gt;
tool layer, not in a prompt the next model release might ignore.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Treat 404 as a &lt;strong&gt;null-valued result&lt;/strong&gt;, not an error. Return &lt;code&gt;{"value": None}&lt;/code&gt;&lt;br&gt;
instead of raising — and keep raising on everything that's a &lt;em&gt;real&lt;/em&gt; fault (5xx,&lt;br&gt;
connection errors, timeouts), because those are exactly what the breaker &lt;em&gt;should&lt;/em&gt;&lt;br&gt;
see and act on.&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch a value object from the upstream API.

    A 404 means the upstream simply doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t publish that path — a normal
    &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; result, not a failure. We return a null-valued dict
    rather than raising, so missing/guessed paths don&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t register as tool
    failures (which can trip the client&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s consecutive-failure circuit
    breaker). Any other HTTP error (5xx, etc.) is a real fault and still
    raises.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/vessels/self/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http&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="n"&gt;url&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# 5xx / other faults still raise — breaker still works
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One branch. Check for 404 &lt;em&gt;before&lt;/em&gt; &lt;code&gt;raise_for_status()&lt;/code&gt;, return a null result, and&lt;br&gt;
let &lt;code&gt;raise_for_status()&lt;/code&gt; handle the genuinely-broken cases. The breaker still&lt;br&gt;
protects you against a real upstream outage (a string of 500s still trips it), but&lt;br&gt;
a guess-storm of "not published" paths now flows through harmlessly as&lt;br&gt;
&lt;code&gt;value=None&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Apply the same rule to any endpoint where absence is a valid answer — a tree-walk&lt;br&gt;
for path discovery, a sub-resource fetch, etc.:&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_subtree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;root&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http&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="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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/api/.../&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;root&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;            &lt;span class="c1"&gt;# nothing published there yet — empty, not broken
&lt;/span&gt;    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a quiet bonus: a &lt;strong&gt;present-but-null&lt;/strong&gt; value (e.g. course while stationary,&lt;br&gt;
which the API returns as &lt;code&gt;null&lt;/code&gt;) and an &lt;strong&gt;absent&lt;/strong&gt; path (404) now surface as the&lt;br&gt;
&lt;em&gt;same&lt;/em&gt; shape — &lt;code&gt;value=None&lt;/code&gt;. The agent gets one consistent "unavailable" concept&lt;br&gt;
to reason about, instead of one path that returns &lt;code&gt;null&lt;/code&gt; and another that throws.&lt;/p&gt;

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

&lt;p&gt;This generalizes past SignalK to &lt;strong&gt;any&lt;/strong&gt; agent/MCP tool sitting over an HTTP API&lt;br&gt;
where "absent" is a legitimate result. The pattern is always the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Distinguish "absent" from "broken" at the HTTP boundary.&lt;/strong&gt; 404 (and often an
empty 200) means &lt;em&gt;the data isn't there&lt;/em&gt; — an answer, not a failure. 5xx,
connection refused, and timeouts mean &lt;em&gt;the system is broken&lt;/em&gt; — those are
failures, and the breaker should see them. Collapsing the two with a blanket
&lt;code&gt;raise_for_status()&lt;/code&gt; is the root mistake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Circuit breakers amplify a mislabeled error into cascading tool loss.&lt;/strong&gt; A
breaker is a multiplier: misclassify one normal response as a failure and the
breaker turns a handful of them into &lt;em&gt;every&lt;/em&gt; subsequent call failing for a
cooldown window. The blast radius of "404 raises" is not one bad read — it's all
the good reads behind it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents guess; assume bursts of "not found."&lt;/strong&gt; Any model driving a path- or
ID-addressed API will probe wrong keys, especially smaller local models. Don't
treat that as pathological — treat &lt;em&gt;absence&lt;/em&gt; as a first-class, non-failing
result so guessing is cheap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't blunt the breaker to paper over this.&lt;/strong&gt; Raising the threshold or
retrying 404s both make the breaker worse at its actual job (catching real
outages) while only delaying the false trip. Fix the classification, not the
breaker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It looks like an upstream crash but isn't.&lt;/strong&gt; When the breaker opens
client-side, the upstream's access log just goes quiet — the later requests
never leave the agent. Check the runtime's failure counter before you go
debugging a server that's perfectly healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The one-line test for whether a status code should raise: &lt;em&gt;would a healthy system,&lt;br&gt;
correctly used, ever return this?&lt;/em&gt; For 404-on-a-missing-resource, the answer is&lt;br&gt;
yes — so it must not raise.&lt;/p&gt;

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

&lt;p&gt;This came out of building the AI ops layer for an all-electric charter catamaran —&lt;br&gt;
a local LLM driving MCP tools over a SignalK marine-data server. The tool that&lt;br&gt;
wraps SignalK is open source, 404-handling and all:&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-mcp&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>agents</category>
      <category>api</category>
    </item>
    <item>
      <title>Logging VHF DSC distress calls in SignalK (PGN 129808)</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 08:35:18 +0000</pubDate>
      <link>https://dev.to/clarkbw--/logging-vhf-dsc-distress-calls-in-signalk-pgn-129808-2kp0</link>
      <guid>https://dev.to/clarkbw--/logging-vhf-dsc-distress-calls-in-signalk-pgn-129808-2kp0</guid>
      <description>&lt;p&gt;A DSC distress alert is the most important packet a marine VHF will ever hand you, and it's structured data. When a vessel hits the red button, its radio transmits a digital selective calling burst on &lt;strong&gt;channel 70&lt;/strong&gt;: format specifier, the sender's MMSI, category, nature of distress, position, and UTC time — encoded per ITU-R M.493. A DSC-equipped radio that hears it re-emits it to your network, as &lt;code&gt;$--DSC&lt;/code&gt;/&lt;code&gt;$--DSE&lt;/code&gt; sentences on &lt;strong&gt;NMEA 0183&lt;/strong&gt; or as &lt;strong&gt;PGN 129808&lt;/strong&gt; on &lt;strong&gt;NMEA 2000&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That burst arrives — and stays readable — even when the follow-up voice MAYDAY on channel 16 is garbled, stepped on, or out of range. If you might be the nearest boat, you want every received alert stored with its position and surfaced as an alarm.&lt;/p&gt;

&lt;p&gt;Stock SignalK quietly drops most of it. This is the broke → tried → fixed of getting &lt;strong&gt;DSC distress calls into SignalK&lt;/strong&gt;, with receipts for every gap. The result is &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test sentence — try it without a radio
&lt;/h2&gt;

&lt;p&gt;You don't need a transmitting vessel in distress to reproduce all of this. Feed two sentences through any NMEA 0183 input (TCP, UDP, file playback):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$CDDSC,12,3380400790,12,05,00,1423108312,2019,,,S,E*69
$CDDSE,1,1,A,3380400790,00,45894494*1B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a &lt;strong&gt;format 12&lt;/strong&gt; (distress alert), from MMSI &lt;code&gt;338040079&lt;/code&gt; (the sentence carries it &lt;code&gt;* 10&lt;/code&gt;, with a trailing zero), &lt;strong&gt;category 12&lt;/strong&gt; (distress), &lt;strong&gt;nature 05&lt;/strong&gt; (sinking), a position field, UTC &lt;code&gt;2019&lt;/code&gt;, and the &lt;code&gt;E&lt;/code&gt; expansion flag saying &lt;em&gt;a &lt;code&gt;$--DSE&lt;/code&gt; sentence follows&lt;/em&gt;. The second sentence is that expansion, refining the position. Both together are one incident.&lt;/p&gt;

&lt;p&gt;Now walk through what stock SignalK does with each, and where it falls short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gap 1: the 0183 hook misses sparse distress alerts — and persists nothing
&lt;/h2&gt;

&lt;p&gt;The DSC field layout (sentence id and checksum already stripped) is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        0  1          2  3  4  5          6    7 8 9 10
        |  |          |  |  |  |          |    | | | |
 $--DSC,XX,XXXXXXXXXX,XX,XX,XX,XXXXXXXXXX,XXXX,,,A,C*hh

  0: format specifier (12 = distress alert)
  2: category (00 routine / 08 safety / 10 urgency / 12 distress)
  3: nature of distress
  5: position (quadrant + ddmm + dddmm)
  6: UTC time hhmm
 10: expansion flag — 'E' means a $--DSE sentence follows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upstream issue here is &lt;a href="https://github.com/SignalK/nmea0183-signalk/issues/217" rel="noopener noreferrer"&gt;SignalK/nmea0183-signalk#217&lt;/a&gt;: some radios emit a distress alert with the &lt;strong&gt;category field empty&lt;/strong&gt; — it's redundant, because format &lt;code&gt;112&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; a distress alert by definition. The stock parser treats the empty category as "not a distress call" and the alert evaporates.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;signalk-dsc&lt;/code&gt; registers its own &lt;code&gt;DSC&lt;/code&gt; parser (a superset of the stock hook) that infers the category from the format when it's missing:&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;let&lt;/span&gt; &lt;span class="nx"&gt;categoryCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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="c1"&gt;// Some radios omit the category on distress alerts — it is implied by&lt;/span&gt;
&lt;span class="c1"&gt;// format 112 (see SignalK/nmea0183-signalk#217).&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;categoryCode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;formatCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;categoryCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12&lt;/span&gt;&lt;span class="dl"&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 whole parser is tolerant by design: anything it can't interpret stays &lt;code&gt;undefined&lt;/code&gt;, and the &lt;strong&gt;raw sentence is always kept&lt;/strong&gt; alongside the parsed fields. That's the second half of the gap — even when the stock hook &lt;em&gt;does&lt;/em&gt; fire, it raises a transient notification and stores nothing. There is no record afterward. A distress alert with no persistence is a distress alert you can't act on the moment you look away from the screen.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;signalk-dsc&lt;/code&gt; appends every received call to an on-disk JSONL log and serves the history at &lt;code&gt;/signalk/v2/api/resources/dsc-calls&lt;/code&gt; (anonymously readable under &lt;code&gt;allow_readonly&lt;/code&gt;):&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerResourceProvider&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;dsc-calls&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;methods&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;listResources&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* every stored call, by id */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;getResource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* one call */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;setResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dsc-calls is read-only&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;deleteResource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dsc-calls is read-only&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The store is deliberately boring — append-only JSONL, synchronous I/O, full-file compaction past a cap. DSC traffic is rare (a busy day within range of a coast station might see dozens of calls), so the simplest thing that survives a power cut mid-write wins: a torn last line is skipped on load, the rest is kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gap 2: $--DSE position refinement, which the stock parser ignores entirely
&lt;/h2&gt;

&lt;p&gt;The position in the DSC sentence is truncated to whole minutes — about &lt;strong&gt;±1 NM&lt;/strong&gt;. That's coarse for a search. The optional &lt;code&gt;$--DSE&lt;/code&gt; expansion sentence carries the missing precision, down to ten-thousandths of a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;        0 1 2 3          4  5
        |_|_|_|__________|__|________
 $--DSE,t,n,A,XXXXXXXXXX,00,llllyyyy*hh

  3: address — MMSI * 10, same convention as DSC
  4+: (code, data) pairs; code 00 = enhanced position,
      data = 4 digits lat + 4 digits lon, in 1/10000 of a minute
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stock parser doesn't handle DSE at all. &lt;code&gt;signalk-dsc&lt;/code&gt; pairs an incoming DSE with the matching DSC call it stored in the last two minutes (same MMSI, position still at minute resolution) and refines it. DSC truncates &lt;em&gt;toward zero&lt;/em&gt;, so the fractional minutes always extend the magnitude — sign-preserving:&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;refinePosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ext&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;extend&lt;/span&gt; &lt;span class="o"&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="nx"&gt;minuteFraction&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;sign&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sign&lt;/span&gt; &lt;span class="o"&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;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;minuteFraction&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&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="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latMinuteFraction&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;position&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="nx"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lonMinuteFraction&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;The stored call's &lt;code&gt;positionResolution&lt;/code&gt; flips from &lt;code&gt;minute&lt;/code&gt; to &lt;code&gt;enhanced&lt;/code&gt;, and the refined position goes out as a delta. ±1 NM becomes a tight fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gap 3: n2k-signalk has no PGN 129808 mapping at all
&lt;/h2&gt;

&lt;p&gt;On NMEA 2000, a DSC call is &lt;strong&gt;PGN 129808&lt;/strong&gt;, "DSC Call Information." &lt;code&gt;n2k-signalk&lt;/code&gt; — the converter that turns N2K into SignalK deltas — produces &lt;strong&gt;no delta for it&lt;/strong&gt;. There's nothing to subscribe to.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;signalk-dsc&lt;/code&gt; goes one layer down and listens to the server's analyzer stream directly:&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;DSC_PGN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;129808&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;onPgn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pgnData&lt;/span&gt;&lt;span class="p"&gt;)&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;started&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pgnData&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pgnData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pgn&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;DSC_PGN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizePgn129808&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pgnData&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n2k&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pgnData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fields&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="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;N2KAnalyzerOut&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onPgn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normalizing 129808 is fiddly because canboatjs may resolve the enumerations to their names &lt;em&gt;or&lt;/em&gt; pass through the raw ITU symbol numbers when a lookup misses, and the distress vs. general variants use different field names. The mapping handles both — name and number — for format, category, and nature:&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;NATURES&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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sinking&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;sinking&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;man overboard&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;mob&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flooding&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;flooding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// ...resolved names above; raw ITU symbols below&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sinking&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mob&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flooding&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both transports — 0183 and N2K — land in the same canonical event shape, so everything downstream (storage, alarms, logbook) is transport-agnostic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alarms under your own vessel — and surviving a restart
&lt;/h2&gt;

&lt;p&gt;Every received distress/urgency/safety call raises a notification under &lt;em&gt;self&lt;/em&gt; so the vessel's own alarm chain fires. The category maps straight onto SignalK's severity ladder:&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;NOTIFICATION_STATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;distress&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&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;urgency&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="na"&gt;safety&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alert&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;// → notifications.dsc.distress / .urgency / .safety&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routine calls never alarm. DSC distress alerts &lt;strong&gt;auto-repeat until acknowledged&lt;/strong&gt;, so a naive implementation would re-alarm every few minutes; instead, a repeat inside a 5-minute window bumps a &lt;code&gt;repeats&lt;/code&gt; counter on the stored call rather than firing again.&lt;/p&gt;

&lt;p&gt;The subtle one: SignalK notifications live &lt;strong&gt;in memory&lt;/strong&gt;. If the server restarts mid-incident, an active distress alarm silently vanishes — and a received MAYDAY must not disappear because the server bounced. So on start, &lt;code&gt;signalk-dsc&lt;/code&gt; re-raises the newest alert per category that's still fresh (within the last hour):&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;reannounceTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;reannounced&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;Set&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;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;NOTIFICATION_STATES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;reannounced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastReceivedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receivedAt&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;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;REANNOUNCE_WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;reannounced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&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="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 30-second delay is deliberate: it lets position providers come up first, so the spoken alert can say "2.3 nautical miles northwest" instead of reading out raw coordinates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Voice-sized message vs. full log detail
&lt;/h2&gt;

&lt;p&gt;The notification message gets &lt;strong&gt;spoken&lt;/strong&gt; by a voice pipeline, so it's deliberately minimal — type, vessel, situation, range and direction from own position, action, and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DSC distress alert: vessel Wind Chaser, sinking, 2.3 nautical miles
northwest. Monitor channel 16.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; spoken: the MMSI. Text-to-speech reads &lt;code&gt;366123456&lt;/code&gt; as "three hundred sixty-six million…", which is useless and slow on a safety alert. (The plugin never speaks an MMSI — a small but real design rule.) The full detail lands in the call log and a GMDSS-style logbook entry instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[DSC] DISTRESS alert from Wind Chaser (MMSI 338040079): sinking.
position 48°47.700′N 123°12.300′W at 20:19 UTC, 2.3 NM northwest of us. via nmea0183
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The vessel name, when present, comes from AIS static data already in the data model — not from the DSC sentence, which only carries the MMSI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a &lt;em&gt;log&lt;/em&gt;, specifically
&lt;/h2&gt;

&lt;p&gt;Capturing the alert and alarming on it is the operational case. The reason to also &lt;strong&gt;persist it as a log entry&lt;/strong&gt; is older than SignalK by about a century.&lt;/p&gt;

&lt;p&gt;Recording distress traffic is the long-standing radio-station regulatory standard. The cleanest US statement is &lt;strong&gt;&lt;a href="https://www.law.cornell.edu/cfr/text/47/80.409" rel="noopener noreferrer"&gt;47 CFR § 80.409&lt;/a&gt;&lt;/strong&gt; (FCC station logs). The radiotelegraph-log subsection, &lt;strong&gt;(d)(4)&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All distress calls, automatic-alarm signals, urgency and safety signals &lt;strong&gt;made or intercepted&lt;/strong&gt;, the complete text, if possible, of distress messages and distress communications, and any incidents or occurrences which may appear to be of importance to safety of life or property at sea, must be entered, together with the time of such observation or occurrence and the position of the ship or other mobile unit in need of assistance.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The load-bearing phrase is &lt;strong&gt;"made or intercepted."&lt;/strong&gt; The rule isn't only about the distress &lt;em&gt;you&lt;/em&gt; declare — it explicitly covers the traffic you &lt;em&gt;receive&lt;/em&gt;. That's exactly what a DSC receiver does: it intercepts. The required fields — text of the distress, the time, and the position of the station in need of assistance — are precisely what a DSC burst (plus its DSE refinement) carries, which is why the plugin stores all three. The radiotelephone-log equivalent is subsection &lt;strong&gt;(e)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest caveat, because a knowledgeable reader will poke at an overstated claim:&lt;/strong&gt; applicability is subsection &lt;strong&gt;(f)&lt;/strong&gt;, and the log &lt;em&gt;mandate&lt;/em&gt; binds &lt;strong&gt;compulsorily-equipped / GMDSS / SOLAS-class / Great Lakes / Bridge-to-Bridge&lt;/strong&gt; stations — &lt;strong&gt;not&lt;/strong&gt; voluntary recreational stations. A private sailor is generally &lt;em&gt;not&lt;/em&gt; legally required to keep this log. Internationally the same duty flows from &lt;strong&gt;SOLAS Chapter IV&lt;/strong&gt; and the &lt;strong&gt;ITU Radio Regulations&lt;/strong&gt;; Canada's equivalent is &lt;strong&gt;TP 1539&lt;/strong&gt; (Ship Station Radio Technical Regulations), which is the relevant one for a boat working BC waters.&lt;/p&gt;

&lt;p&gt;So the accurate framing is narrow and worth getting right: the law doesn't force a recreational station to keep a distress log — but the standard it sets for stations that &lt;em&gt;are&lt;/em&gt; required to is a good one, and the data to meet it is already arriving on your network. &lt;code&gt;signalk-dsc&lt;/code&gt; gives you that same SOLAS-grade record automatically. (For the practical side of receiving DSC distress alerts, the USCG NavCen &lt;a href="https://www.navcen.uscg.gov/dsc-distress" rel="noopener noreferrer"&gt;DSC Distress&lt;/a&gt; page is the reference.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$--DSC ─┐
$--DSE ─┼─→ canonical DSC event ─┬─→ JSONL log  (/resources/dsc-calls)
PGN 129808 ─┘                    ├─→ notifications.dsc.&amp;lt;category&amp;gt;  (alarm chain)
                                 ├─→ voice-sized spoken message
                                 ├─→ remote-vessel position delta  (chartplotter)
                                 └─→ GMDSS-style logbook entry  (signalk-logbook)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two wire formats, three things stock SignalK drops, one canonical event, five sinks. A received distress alert no longer vanishes the moment you look away.&lt;/p&gt;

&lt;p&gt;This is one piece of an all-electric charter-catamaran ops stack — the radio should keep the log the regulations describe whether or not anyone's watching the screen. Code's here: &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>nmea0183</category>
      <category>nmea2000</category>
    </item>
    <item>
      <title>Why your agent ignores its skill body but obeys the system prompt</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 04:41:06 +0000</pubDate>
      <link>https://dev.to/clarkbw--/why-your-agent-ignores-its-skill-body-but-obeys-the-system-prompt-2a3b</link>
      <guid>https://dev.to/clarkbw--/why-your-agent-ignores-its-skill-body-but-obeys-the-system-prompt-2a3b</guid>
      <description>&lt;p&gt;You edit a skill's prompt to fix a behaviour. You confirm the change is in the file the runtime loads. You restart. The agent does the old thing anyway. Not &lt;em&gt;sometimes&lt;/em&gt; — reliably, for most queries.&lt;/p&gt;

&lt;p&gt;The instinct is to edit the prompt harder. The actual problem was that the prompt you edited wasn't in the request at all. On a skill/agent framework with a base system prompt plus conditionally-loaded skill bodies, &lt;strong&gt;a lot of your "prompt" only loads when a skill triggers&lt;/strong&gt; — and if the rule needs to hold on &lt;em&gt;every&lt;/em&gt; query, it has to live in the always-on layer instead. The second half of the same bug: the always-on file had no deploy step, so the running copy had silently drifted from the repo.&lt;/p&gt;

&lt;p&gt;This is framework-general. We hit it on a local &lt;a href="https://github.com/NousResearch/Hermes" rel="noopener noreferrer"&gt;Hermes&lt;/a&gt; agent, but the same shape applies to anything with the now-common base-prompt-plus-&lt;code&gt;SKILL.md&lt;/code&gt; split — &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview" rel="noopener noreferrer"&gt;Claude Agent Skills&lt;/a&gt;, &lt;a href="https://geminicli.com/docs/cli/skills/" rel="noopener noreferrer"&gt;Gemini CLI skills&lt;/a&gt;, VS Code agent skills, and others.&lt;/p&gt;

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

&lt;p&gt;Our agent stack has a shared persona (&lt;code&gt;SOUL.md&lt;/code&gt;) and per-agent skills (Navigator, Engineer, Logbook). The persona names the vessel and sets the voice; each skill layers on its own job. We changed the vessel name in the Navigator skill body, deployed it, confirmed the runtime file on disk:&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;-n&lt;/span&gt; &lt;span class="s2"&gt;"VESSEL_NAME_HERE&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;aboard"&lt;/span&gt; ~/.hermes/skills/naturali/navigator/SKILL.md
1:Navigator agent aboard s/v Naturali.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct name, correct file, correct path. Restart. Then ask it a plain question:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; how's our depth?
Aboard s/v Wrongboat, depth is 8.2 metres below the keel, Captain.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still the old name. The file the runtime loads says the right thing. The agent says the wrong thing. Editing the skill body again — any number of times — changed nothing for this query.&lt;/p&gt;

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

&lt;p&gt;Stop theorising about what the model "should" see and look at what it &lt;em&gt;actually&lt;/em&gt; received. Hermes writes a full request dump per call; most frameworks have an equivalent (a debug log, &lt;code&gt;--print-prompt&lt;/code&gt;, a proxy capture). Dump the request and grep 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="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; ~/.hermes/sessions/request_dump_&lt;span class="k"&gt;*&lt;/span&gt;.json | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
/Users/me/.hermes/sessions/request_dump_1733270400.json

&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"ship's computer"&lt;/span&gt; ~/.hermes/sessions/request_dump_&lt;span class="k"&gt;*&lt;/span&gt;.json | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
ship&lt;span class="s1"&gt;'s computer            # &amp;lt;-- base persona text IS present

$ grep -o "Navigator agent aboard" ~/.hermes/sessions/request_dump_*.json | tail -1
                           # &amp;lt;-- nothing. the skill body is NOT in the prompt
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. For a plain &lt;code&gt;"how's our depth?"&lt;/code&gt;, the &lt;strong&gt;base persona was in the prompt and the Navigator skill body was not.&lt;/strong&gt; The query didn't activate the Navigator skill, so its &lt;code&gt;SKILL.md&lt;/code&gt; body never got loaded — and the vessel name we'd been so carefully editing lived only in that body.&lt;/p&gt;

&lt;p&gt;This is exactly the &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview" rel="noopener noreferrer"&gt;progressive disclosure&lt;/a&gt; model these frameworks are built on, stated plainly in Anthropic's own Agent Skills docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Level 1: Metadata (always loaded)&lt;/strong&gt; ... Claude loads this metadata at startup and includes it in the system prompt.&lt;br&gt;
&lt;strong&gt;Level 2: Instructions (loaded when triggered)&lt;/strong&gt; ... When you request something that matches a Skill's description, Claude reads SKILL.md from the filesystem. Only then does this content enter the context window.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a skill's &lt;code&gt;name&lt;/code&gt;/&lt;code&gt;description&lt;/code&gt; is always in context (that's how the model knows the skill exists), but the &lt;strong&gt;body&lt;/strong&gt; — your actual instructions — only loads when that skill fires. The base system prompt, by contrast, is on every single request. If an instruction must always apply, it belongs in the always-on layer. Vessel identity is an always-apply rule. It was in the wrong layer.&lt;/p&gt;

&lt;p&gt;The wrong name was the &lt;em&gt;symptom&lt;/em&gt; that exposed this, but the principle generalises to every always-on invariant: identity, time/units discipline, and anti-fabrication rules all silently fail to apply on any query that doesn't trigger the skill that contained them.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Attempt 1 — edit the skill body and redeploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# fixed the name in the skill source, redeployed&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;scripts/deploy-navigator.sh
deploy-navigator: wrote ~/.hermes/skills/naturali/navigator/SKILL.md &lt;span class="o"&gt;(&lt;/span&gt;vessel: Naturali&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; how's our depth?
Aboard s/v Wrongboat, depth is 8.2 metres below the keel, Captain.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No change. The file is right; the query just never loads it. You can edit a conditionally-loaded body forever and it will never affect a query that doesn't trigger the skill.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2 — assume the file didn't deploy and re-check the disk
&lt;/h3&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;cat&lt;/span&gt; ~/.hermes/skills/naturali/navigator/SKILL.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
Navigator agent aboard s/v Naturali.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime file was correct the whole time. This rules out a deploy failure and is the moment that &lt;em&gt;should&lt;/em&gt; have redirected us to "is this file even being loaded?" instead of "is this file correct?" — two very different questions. We'd been answering the wrong one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3 — add the identity rule to the skill body harder
&lt;/h3&gt;

&lt;p&gt;We considered duplicating the name/identity statement into every skill body so at least the loaded skill would carry it. That "works" only for queries that trigger &lt;em&gt;a&lt;/em&gt; skill, leaves bare/social/ambiguous queries (which trigger none) still wrong, and creates N copies of an invariant to keep in sync — the classic drift trap. It treats the symptom. The rule isn't per-skill; it's always-on. It belongs in the one prompt that's always-on.&lt;/p&gt;

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

&lt;p&gt;Move always-on invariants into the always-on layer (the base persona), and template the vessel name so nothing is hardcoded:&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="c"&gt;&amp;lt;!-- SOUL.md — the base persona, loaded on EVERY request --&amp;gt;&lt;/span&gt;
You are the ship's computer aboard s/v {{VESSEL_NAME}}.
You address the user as "Captain."
Never speak a raw UTC timestamp in conversation.
Report a reading and its source path; never narrate whether data is
"live," from a "mock," or whether the vessel is "ashore" — you aren't
given that and must not guess it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then give that file a real deploy step — substituting the name from one source of truth — instead of hand-placing it once:&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;# scripts/deploy-soul.sh&lt;/span&gt;
&lt;span class="nv"&gt;vessel_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;resolve_vessel_name &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repo_root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# reads the active vessel profile&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s|{{VESSEL_NAME}}|&lt;/span&gt;&lt;span class="nv"&gt;$vessel_name&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$repo_root&lt;/span&gt;&lt;span class="s2"&gt;/SOUL.md"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HERMES_HOME&lt;/span&gt;&lt;span class="s2"&gt;/SOUL.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; how's our depth?
Aboard s/v Naturali, depth is 8.2 metres below the keel, Captain.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right name, on a query that triggers no skill — because the identity now rides on the always-on persona, not a conditional body.&lt;/p&gt;

&lt;p&gt;The skill body keeps only what's genuinely conditional — operational hints that are &lt;em&gt;fine&lt;/em&gt; to load only when the skill fires:&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="c"&gt;&amp;lt;!-- skills/navigator/body.md — loaded only when Navigator triggers --&amp;gt;&lt;/span&gt;
For "how's our depth?", read environment.depth.belowKeel, not belowTransducer.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;&lt;strong&gt;Sort every prompt rule into always-on vs conditional, and place it accordingly.&lt;/strong&gt; Ask one question of each rule: &lt;em&gt;does this need to hold on a query that triggers no skill?&lt;/em&gt; If yes, it goes in the base prompt (or, for data-shape rules, the deterministic tool layer — but that's &lt;a href="https://engineering.sailingnaturali.com/ha-2026.5-hassil-3-wildcard-voice-routing-missinglisterror/" rel="noopener noreferrer"&gt;another post&lt;/a&gt;). If it's only relevant inside a task, the skill body is the right, token-cheap home. Identity, safety, units, and anti-fabrication rules are almost always the former.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dump the request before you touch a prompt.&lt;/strong&gt; "The prompt says X but the agent does Y" has two failure modes that look identical from the outside: the prompt is wrong, or the prompt isn't loaded. Only the request dump tells them apart. We burned two debug cycles editing a file that was never in the request. One &lt;code&gt;grep&lt;/code&gt; of the dumped prompt would have caught it immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every runtime prompt artifact needs a deploy step, or it rots.&lt;/strong&gt; The second half of this bug: our &lt;code&gt;~/.hermes/SOUL.md&lt;/code&gt; had been hand-placed once, months earlier, and named a since-renamed boat. Only the &lt;em&gt;skill&lt;/em&gt; had a deploy script, so only the skill tracked the repo. A prompt file you copy into place by hand has no mechanism to tell you it diverged — it silently runs stale. We fixed it by giving &lt;code&gt;SOUL.md&lt;/code&gt; the same treatment the skill already had:&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;# .git/hooks/pre-commit — redeploy whatever source changed&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Eq&lt;/span&gt; &lt;span class="s1"&gt;'^(SOUL\.md$|scripts/(deploy-soul|vessel-name)\.sh$)'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;scripts/deploy-soul.sh    &lt;span class="c"&gt;# base persona redeployed on every relevant commit&lt;/span&gt;
&lt;span class="k"&gt;fi
if &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Eq&lt;/span&gt; &lt;span class="s1"&gt;'^skills/navigator/'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;scripts/deploy-navigator.sh
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a commit that edits either source redeploys it. The runtime can't drift from the repo without a commit saying so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One source of truth for shared values.&lt;/strong&gt; The vessel name resolves from the active vessel profile — the same profile that seeds the data-layer base values — so the boat is named identically in the data model and in the persona, and switching boats needs zero prompt edits. Don't hardcode a value that appears in two layers; template both from one resolver. A hardcoded value in two places is a drift waiting to happen.&lt;/p&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 with a shared persona and per-job skills over a marine data bus — where the agent had better call the boat by its actual name, on every query, not just navigation ones. The agent skills and deploy scripts are open source: &lt;a href="https://github.com/sailingnaturali/naturali-agents" rel="noopener noreferrer"&gt;github.com/sailingnaturali/naturali-agents&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>skills</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Why Claude won't transcribe your PDF — and what to do instead</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 04:41:03 +0000</pubDate>
      <link>https://dev.to/clarkbw--/why-claude-wont-transcribe-your-pdf-and-what-to-do-instead-d5i</link>
      <guid>https://dev.to/clarkbw--/why-claude-wont-transcribe-your-pdf-and-what-to-do-instead-d5i</guid>
      <description>&lt;p&gt;We needed a public, machine-readable vault of the navigation rules of the road — COLREGS rule text — to back an MCP server for a boat agent. The obvious plan: hand Claude the USCG &lt;em&gt;Navigation Rules and Regulations Handbook&lt;/em&gt; PDF and ask it to transcribe each rule into a clean markdown file. The text is a US Government work in the public domain. Free to copy, free to redistribute.&lt;/p&gt;

&lt;p&gt;Claude read the whole PDF, started writing, and then the Anthropic API hard-blocked it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: 400 Output blocked by content filtering policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It blocked again. And again. Eleven blocked responses across two sessions before we gave up on transcription entirely. This post is the dead-end in full — the attempts, the repeating error, and the fix that turned out to be the better architecture anyway: &lt;strong&gt;don't route source text through model output. Have the model write a parser, and let the parser write the files.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The symptom is a &lt;code&gt;400&lt;/code&gt; from the Messages API mid-task, surfaced by whatever's driving the model (Claude Code, an SDK call, an agent loop) as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: 400 Output blocked by content filtering policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fires &lt;em&gt;late&lt;/em&gt; — after the model has read the source document and started emitting. The read succeeds. The generation is what dies. And it is sticky: once your task is "read this document and write out its text," every subsequent response in the session tends to get blocked too, including replies that contain no document text at all.&lt;/p&gt;

&lt;p&gt;This is not a rate limit, not a context-length error, not a malformed request. It's an &lt;strong&gt;output-side classifier&lt;/strong&gt;. Anthropic documents it plainly in their privacy center:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Claude's purpose is to generate new content and ideas, not to reproduce content that already exists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's an anti-regurgitation guard. It watches the &lt;em&gt;output&lt;/em&gt; stream for sustained verbatim reproduction of source material and trips when it sees it. Two consequences fall straight out of that design, and both bit us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It is license-blind.&lt;/strong&gt; The classifier can't see provenance or licensing. Public-domain USCG rule text trips it exactly like copyrighted prose would. "But this is legal to copy" is not an argument the filter can hear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is API-level, so you can't route around it.&lt;/strong&gt; Subagents, different sessions, retries — they all hit the same classifier. The trigger is the &lt;em&gt;shape of the task&lt;/em&gt; (long verbatim copying into output), not a transient.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You're far from alone if you've hit this. It's a long-running source of false positives in Claude Code — &lt;a href="https://github.com/anthropics/claude-code/issues/18542" rel="noopener noreferrer"&gt;generating a LICENSE file&lt;/a&gt;, &lt;a href="https://github.com/anthropics/claude-code/issues/4210" rel="noopener noreferrer"&gt;adding a Mozilla license&lt;/a&gt;, &lt;a href="https://github.com/anthropics/claude-code/issues/60361" rel="noopener noreferrer"&gt;the Contributor Covenant code of conduct&lt;/a&gt;, &lt;a href="https://github.com/anthropics/claude-code/issues/24869" rel="noopener noreferrer"&gt;extracting a peer-reviewed paper&lt;/a&gt;, even &lt;a href="https://github.com/anthropics/claude-code/issues/41197" rel="noopener noreferrer"&gt;benign markdown edits&lt;/a&gt;. The common thread in all of them: the model was about to emit a long span that closely matches existing text.&lt;/p&gt;

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

&lt;p&gt;The mental model that unlocks the fix: &lt;strong&gt;the filter cares about your output, not your intent.&lt;/strong&gt; It does not matter that the text is public domain. It does not matter that you have the file open in front of you. It does not matter that &lt;em&gt;you&lt;/em&gt; extracted the text and you're just asking the model to echo it back. If a long verbatim span of pre-existing material flows through the model's output, the classifier can trip.&lt;/p&gt;

&lt;p&gt;That last point is the one people miss. We tripped it once when the assistant &lt;em&gt;merely echoed already-extracted vault text back into chat while summarizing progress&lt;/em&gt;. The text had already been pulled out of the PDF by other means — the model was just quoting it in conversation. Output is output. Where the text came from is invisible to the classifier.&lt;/p&gt;

&lt;p&gt;So "transcribe this PDF to markdown" is a near-perfect trigger. It is, by definition, a request to emit a long verbatim span of an existing document. The task and the thing the filter blocks are the same task.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Attempt 1 — just transcribe it
&lt;/h3&gt;

&lt;p&gt;Point the model at the PDF, ask for clean markdown per rule. Here's a reconstructed version of the task list it built (the structure is real; the wording is reconstructed, not a verbatim transcript):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;TODO
[ ] Transcribe Rules 1–13  → rules/international/rule-01.md … rule-13.md
[ ] Transcribe Rules 14–31
[ ] Transcribe Rules 32–38
[ ] Transcribe Annexes I–V  (PDF pages 121–146)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It picked up task 1, read the pages, started writing — and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: 400 Output blocked by content filtering policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In one session, 11 rule files landed before the wall went up; the rest were abandoned. In a later, dedicated transcription session it was worse — &lt;strong&gt;six blocked responses, zero files written.&lt;/strong&gt; Every single response after it picked up the first task got blocked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 2 — retry
&lt;/h3&gt;

&lt;p&gt;The instinct on a &lt;code&gt;400&lt;/code&gt; is to retry. So we retried. Same task, fresh response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: 400 Output blocked by content filtering policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrying never works here, and now you know why: the trigger is the task shape, not a transient fault. Same input, same classifier, same block. We even asked the model, in plain chat with no document text in the reply, &lt;em&gt;"are you working, or are we stuck?"&lt;/em&gt; — and &lt;strong&gt;that&lt;/strong&gt; got blocked too, because the session was now anchored on a verbatim-reproduction task.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt 3 — dispatch a subagent
&lt;/h3&gt;

&lt;p&gt;Maybe a fresh subagent with a tighter prompt would behave. We dispatched one whose whole job was authoring rule files. It got two blocks of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error: 400 Output blocked by content filtering policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The filter is API-level. A subagent is just more Messages API calls — it routes through the exact same classifier. There is no "inner" model that isn't filtered.&lt;/p&gt;

&lt;p&gt;The tally across two sessions: &lt;strong&gt;11 blocked responses.&lt;/strong&gt; Five on the first day (two of them inside the dispatched subagent, one when the assistant only &lt;em&gt;echoed&lt;/em&gt; already-extracted text), six on the dedicated retry session with no progress at all. The lesson finally landed: you cannot make this task work by asking more nicely, from a different agent, or one more time. The task itself is the thing being blocked.&lt;/p&gt;

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

&lt;p&gt;Stop sending source text through model output. &lt;strong&gt;Have the model write a parser; let the parser write the files.&lt;/strong&gt; The model's output is now Python — novel code, which the filter has no quarrel with — and the rule prose flows file-to-file via &lt;code&gt;pdftotext&lt;/code&gt; and XML parsing, never touching the model's output stream.&lt;/p&gt;

&lt;p&gt;For this vault, three deterministic extractors, one orchestrator. Prefer structured authoritative sources over the PDF where they exist — better provenance &lt;em&gt;and&lt;/em&gt; machine-parseable:&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="c1"&gt;# scripts/build_vault.py — Claude wrote this; it does the extraction, not the model
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ECFR_PARTS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# 33 CFR 83–88, eCFR versioner API XML
&lt;/span&gt;    &lt;span class="n"&gt;xml_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ecfr_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read_text&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;part&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;83&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;ecfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# US Inland rules
&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ecfr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_annex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;justice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;justice_xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retrieved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# Justice Laws XML → Canadian
&lt;/span&gt;
&lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;international_pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;handbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split_pages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_pdftotext&lt;/span&gt;&lt;span class="p"&gt;(&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;handbook_pdf&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;handbook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse_international&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;handbook_pdf&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="c1"&gt;# pdftotext text layer → International
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three sources, each chosen to be machine-parseable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;US Inland   →  eCFR versioner API XML (33 CFR Parts 83–88)         → rules/inland/*.md
Canadian    →  Justice Laws XML (C.R.C., c. 1416)                  → rules/canadian/*.md
International →  pdftotext on the USCG handbook's text layer        → rules/international/*.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command rebuilds the whole vault:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run python scripts/build_vault.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--handbook&lt;/span&gt; &lt;span class="s2"&gt;"/path/to/Nav Rules Handbook_Corrected_08-12-2024.pdf"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;135 generated files, zero filter errors&lt;/strong&gt; — handling the exact same material that blocked 11 times when it went through model output. Each file is a small markdown doc with a structural frontmatter block and the rule body (frontmatter shape only, no rule prose):&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5'&lt;/span&gt;
&lt;span class="na"&gt;regime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;international&lt;/span&gt;
&lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;B&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Look-out"&lt;/span&gt;
&lt;span class="na"&gt;verified&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;source_pdf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Nav Rules Handbook_Corrected_08-12-2024.pdf p. &lt;/span&gt;&lt;span class="m"&gt;19&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one-line reason it works: &lt;strong&gt;the model's output is the parser (novel code), and the corpus text moves file-to-file without ever being emitted by the model.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;This is the better architecture for a safety-critical corpus anyway.&lt;/strong&gt; The filter forced an upgrade:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No hallucination or paraphrase risk.&lt;/strong&gt; A deterministic parser copies bytes; it doesn't "mostly" reproduce a rule. For navigation rules — where a dropped &lt;em&gt;not&lt;/em&gt; changes who gives way — that's not a nicety.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte-identical rebuilds.&lt;/strong&gt; Pin the sources, re-run, get the same vault. The build here is all-or-nothing: it validates the full rule set (expected rule numbers per regime, non-empty prose, no print artifacts, plausible titles) &lt;em&gt;before&lt;/em&gt; writing a single file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human review shrinks.&lt;/strong&gt; Instead of proofreading every transcribed line, a reviewer spot-checks the parser against a sample. Deterministic extraction means a clean sample validates the whole batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few traps we hit, useful if you go this route:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review by diff and sampling, never by printing.&lt;/strong&gt; Echoing extracted prose back into the conversation re-trips the filter &lt;em&gt;even after a clean build&lt;/em&gt;. Verification asserts structure — counts, rule numbers, titles, first characters — never long prose strings. The Canadian parser, for instance, enforces a fidelity invariant: it raises if flattening a provision loses any word character (&lt;code&gt;re.sub(r"\W", "", ...)&lt;/code&gt; on input vs. output must match), so correctness is a code assertion, not a human reading the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-source XML has dialects.&lt;/strong&gt; The eCFR tables are HTML-style &lt;code&gt;TR&lt;/code&gt;/&lt;code&gt;TD&lt;/code&gt;, not the CALS &lt;code&gt;ROW&lt;/code&gt;/&lt;code&gt;ENT&lt;/code&gt; you might assume from other government XML — we silently dropped a whole table before catching it in review. &lt;code&gt;EXTRACT&lt;/code&gt; blocks nest and need recursion. The handbook's text layer wraps section headings mid-line, producing fragments that masquerade as content. Determinism doesn't mean trivial; it means &lt;em&gt;debuggable&lt;/em&gt; — the failures are in code you can read and test, not in a model's mood this turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fails closed, loudly.&lt;/strong&gt; A parser that hits an unhandled element raises with the tag name so you go look, rather than quietly emitting a paraphrase. That's the opposite of the transcription failure mode, where the model would happily "fix up" text it found awkward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general rule, well beyond rules of the road: &lt;strong&gt;any "build a structured corpus from a document" task — re-ingesting a pilot book, turning equipment manuals into spec cards — should extract deterministically, not transcribe.&lt;/strong&gt; If the source is copyrighted, the filter will trip &lt;em&gt;harder&lt;/em&gt;, and you want the deterministic pipeline regardless, for fidelity. The output filter is annoying when it false-positives on a LICENSE file, but on a corpus-ingestion task it's pointing you at the architecture you should have picked first.&lt;/p&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 stack of MCP servers behind a local LLM and a Home Assistant voice front-end, one of which answers questions about the navigation rules of the road. The vault and its build pipeline are open source: &lt;a href="https://github.com/sailingnaturali/colregs-vault" rel="noopener noreferrer"&gt;github.com/sailingnaturali/colregs-vault&lt;/a&gt; (the deterministic &lt;code&gt;scripts/build_vault.py&lt;/code&gt;), consumed by &lt;a href="https://github.com/sailingnaturali/colregs-mcp" rel="noopener noreferrer"&gt;colregs-mcp&lt;/a&gt;. If you've been fighting &lt;code&gt;Output blocked by content filtering policy&lt;/code&gt; on a transcription task, the move is the same: stop transcribing, start parsing.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>anthropic</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why we kept named MCP tools despite a 96% token saving</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 00:13:14 +0000</pubDate>
      <link>https://dev.to/clarkbw--/why-we-kept-named-mcp-tools-despite-a-96-token-saving-40ae</link>
      <guid>https://dev.to/clarkbw--/why-we-kept-named-mcp-tools-despite-a-96-token-saving-40ae</guid>
      <description>&lt;p&gt;The boat-agent stack here runs on a prime directive: if there's something usable out there, improve it; build our own only as a last resort. So when we needed a SignalK MCP server, the honest first move wasn't to write one — it was to evaluate the one that already exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/VesselSense/signalk-mcp-server" rel="noopener noreferrer"&gt;VesselSense/signalk-mcp-server&lt;/a&gt; (TypeScript, MIT) is good work. It exposes SignalK to an agent through a single &lt;code&gt;execute_code&lt;/code&gt; tool: the model writes JavaScript, the server runs it in a sandboxed V8 isolate (&lt;code&gt;isolated-vm&lt;/code&gt;), and only the result comes back. Its README claims a &lt;strong&gt;90–96% token reduction&lt;/strong&gt; versus traditional named MCP tools — 2,000 tokens down to 120 for a vessel-state query, 13,000 down to 300 for a multi-call workflow. Those numbers are plausible, and they line up with the broader industry result that &lt;a href="https://www.anthropic.com/engineering/code-execution-with-mcp" rel="noopener noreferrer"&gt;code execution beats tool-calling on token efficiency&lt;/a&gt; for complex multi-step work.&lt;/p&gt;

&lt;p&gt;We read it, ran the numbers against our own agent, and &lt;strong&gt;kept our discrete-named-tool &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&gt; anyway&lt;/strong&gt; — then harvested three of VesselSense's ideas into our roadmap. This post is that evaluation: the two philosophies, why the obvious-sounding win doesn't bind for a voice-first agent, and a decision framework you can reuse before you adopt-or-build your own MCP server.&lt;/p&gt;

&lt;p&gt;This is a design-reasoning post, not a debugging saga, but it maps to the same arc: a question, the dead-end that looks like an obvious yes, and the call that actually held.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;Two SignalK MCP servers, two genuinely different designs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VesselSense/signalk-mcp-server      sailingnaturali/signalk-mcp
─────────────────────────────       ───────────────────────────
one tool: execute_code              discrete named tools:
  → agent writes JavaScript           read_sensor(path)
  → runs in a V8 isolate              battery_state(bank)
  → queries SignalK, returns          depth_state()
    only the result                   get_route()
                                      get_local_time()
TypeScript / Node + isolated-vm       list_paths(prefix)
claims 90–96% fewer tokens            get_active_alarms()
                                    Python, end-to-end
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adopt-vs-keep question: &lt;strong&gt;does the token-efficiency win bind for our agent?&lt;/strong&gt; If it does, adopting beats maintaining a second server. If it doesn't, the directive doesn't compel adoption — it compels building the right thing for the target.&lt;/p&gt;

&lt;p&gt;The target matters more than anything else here. Our design anchor is a &lt;strong&gt;voice-first agent on a small local model&lt;/strong&gt; — Hermes 3 8B driving a text-to-speech front end on a boat. Not a frontier model in a chat window. That single fact decides the whole evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two valid philosophies, different targets
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;execute_code&lt;/code&gt; is clever, and the token math is real. When an agent needs to fetch every AIS target, filter to the close ones, sort by CPA, and format a summary, the named-tool pattern pays the full input+output token cost of every intermediate call — the model emits a structured call, the whole result flows back into context, repeat. Code execution collapses that into one script and one aggregated result. On a frontier model doing complex, multi-step marine queries, the 90–96% claim is believable.&lt;/p&gt;

&lt;p&gt;But the saving is &lt;em&gt;paid for in one currency: the agent must reliably write correct code.&lt;/em&gt; That's a cheap price for a frontier model and an expensive one for an 8B. The capability gap here is not subtle. From the field:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Small models like llama3.2:3b and llama3.1:8b support tool calling specs but fail inconsistently in practice, especially on sequential or multi-entity commands… Tool calling is the biggest capability gap between local and cloud models — the plumbing exists but model reliability doesn't yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a small model is shaky at emitting a &lt;em&gt;structured tool call&lt;/em&gt;, asking it to emit &lt;em&gt;correct JavaScript&lt;/em&gt; is strictly harder. &lt;code&gt;execute_code&lt;/code&gt; doesn't reduce the model's burden for our agent — it raises it. The token budget was never our binding constraint; reliability is.&lt;/p&gt;

&lt;p&gt;So the comparison isn't "which design is better." It's:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontier model, complex queries, token budget is the constraint&lt;/strong&gt; → &lt;code&gt;execute_code&lt;/code&gt; wins. Adopt VesselSense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small local model, voice front end, reliability is the constraint&lt;/strong&gt; → discrete named tools win. A named tool with one argument — &lt;code&gt;battery_state("house")&lt;/code&gt; — is the most robust thing you can hand an 8B. It cannot get the JavaScript wrong because there is no JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are correct. They're tuned for different agents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the token win doesn't bind #1: the speech contract
&lt;/h2&gt;

&lt;p&gt;Here's the part the token comparison silently drops. Our tools don't return raw SignalK — every value carries a TTS-safe &lt;code&gt;display&lt;/code&gt; string the agent can speak verbatim. SignalK stores everything in SI units and terse codes; a TTS engine mispronounces all of it. Our &lt;a href="https://github.com/sailingnaturali/signalk-mcp/blob/main/SPEC.md" rel="noopener noreferrer"&gt;response contract&lt;/a&gt; makes the spoken form a first-class field:&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;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;The rules behind that &lt;code&gt;display&lt;/code&gt;: spelled-out units (&lt;code&gt;"knots"&lt;/code&gt;, never &lt;code&gt;"kn"&lt;/code&gt;), spelled-out compass points (&lt;code&gt;"North-East"&lt;/code&gt;, never &lt;code&gt;"NE"&lt;/code&gt;), cardinal-name lat/lon, no &lt;code&gt;°T&lt;/code&gt; suffix a TTS engine reads as letters, no ISO timestamp narrated digit by digit. Position is the instructive case — the raw &lt;code&gt;{latitude, longitude}&lt;/code&gt; dict stays in &lt;code&gt;value&lt;/code&gt; for programmatic use, but the agent speaks &lt;code&gt;display&lt;/code&gt;, never the raw pair.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;execute_code&lt;/code&gt; returns whatever the agent's script returns — raw SignalK. That pushes all of this formatting onto the agent, which is &lt;strong&gt;exactly the layer that fails on a small model.&lt;/strong&gt; We've written before about why &lt;a href="https://engineering.sailingnaturali.com/fix-llm-formatting-in-the-tool-layer-not-the-prompt/" rel="noopener noreferrer"&gt;formatting belongs in the tool layer, not the prompt&lt;/a&gt;: a prompt rule is advisory and leaks; the tool response is deterministic. &lt;code&gt;execute_code&lt;/code&gt; is the maximal version of pushing formatting onto the model — it doesn't just leak the contract, it has no place to put one. For a voice-first agent that's disqualifying, and no token saving buys it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the token win doesn't bind #2: 404-as-null and the circuit breaker
&lt;/h2&gt;

&lt;p&gt;The second structural reason is error handling. A SignalK &lt;strong&gt;404 is not an error&lt;/strong&gt; in our client — it means the vessel doesn't publish that path (no such sensor, or a guessed path). The client returns a clean null instead of raising:&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="c1"&gt;# read_sensor on a path the vessel doesn't publish
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;navigation.headingTrue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# not an exception
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is deliberate, and it's a small-model lesson. Agent runtimes commonly run a &lt;strong&gt;per-tool circuit breaker&lt;/strong&gt; — ours (Hermes) trips after 3 consecutive same-tool failures. A small model fanning out across guessed paths on an unfamiliar vessel — &lt;code&gt;headingTrue&lt;/code&gt;, &lt;code&gt;headingMagnetic&lt;/code&gt;, &lt;code&gt;courseOverGroundTrue&lt;/code&gt; — on a boat with no compass would generate a burst of 404s, each counted as a tool failure. That trips the breaker and blocks the &lt;em&gt;valid&lt;/em&gt; reads queued behind it. Returning a clean null keeps a missing path a &lt;em&gt;successful&lt;/em&gt; call, so the breaker never trips on absence.&lt;/p&gt;

&lt;p&gt;Under &lt;code&gt;execute_code&lt;/code&gt;, a missing path is whatever the SignalK client throws inside the isolate, and the agent has to catch and interpret it in code it wrote — on a model that's already at the edge of writing correct code. The named-tool design makes "this sensor doesn't exist" a normal, non-fatal result by construction. That's a reusable lesson for any MCP author building for small models: &lt;strong&gt;decide what your tool does on absence, and make absence a success, not a fault.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The coverage audit: receipts, not vibes
&lt;/h2&gt;

&lt;p&gt;A "prefer adopting" directive still demands a coverage audit — does the existing tool actually do the job, or does it leave the work to the agent? We diffed feature by feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Active alarms.&lt;/strong&gt; VesselSense's &lt;code&gt;getActiveAlarms()&lt;/code&gt; returns alarms with their state and leaves filtering and sorting to client-side code in the isolate — its own example filters with &lt;code&gt;a.state === "alarm" || a.state === "emergency"&lt;/code&gt; &lt;em&gt;in agent-written JS.&lt;/em&gt; Normal-state notifications stay in the result; there's no severity ordering. Ours does that work in the tool, so the agent never writes a filter:&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="c1"&gt;# signalk-mcp: get_active_alarms does the filtering + ordering in-tool
&lt;/span&gt;&lt;span class="n"&gt;_ALARM_SEVERITY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;emergency&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alarm&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warn&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alert&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;_INACTIVE_STATES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;normal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nominal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# normal/nominal dropped; rows sorted worst-severity-first
&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_ALARM_SEVERITY&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also strips the &lt;code&gt;notifications.&lt;/code&gt; prefix off each path so the result feeds straight into our downstream alarm-explanation tooling. Worst-first, normal filtered out, paths cleaned — no JavaScript required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing tools.&lt;/strong&gt; VesselSense's &lt;code&gt;getVesselState&lt;/code&gt; dumps the SignalK tree and lets the agent dig. We have no equivalent dump tool — instead we ship purpose-built ones the dump would otherwise require the agent to assemble: &lt;code&gt;battery_state&lt;/code&gt;, &lt;code&gt;depth_state&lt;/code&gt; (under-keel clearance first, so the agent answers "how close are we to running aground?" without draft math), &lt;code&gt;get_route&lt;/code&gt;, &lt;code&gt;get_local_time&lt;/code&gt; (GPS-aware timezone). Each returns the speakable, contract-compliant answer directly.&lt;/p&gt;

&lt;p&gt;The pattern across the audit: VesselSense pushes the last mile of work — filter, sort, format, interpret — into agent-written code, which is exactly the work a small model is worst at. That's not a flaw in VesselSense; it's the correct division of labor &lt;em&gt;for a frontier model.&lt;/em&gt; It's the wrong division for ours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maturity audit (still matters under "prefer adopting")
&lt;/h2&gt;

&lt;p&gt;Adopting code means inheriting its maintenance. The public signals on the upstream repo, at evaluation time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;last push      2025-11-26   (6+ months dormant)
stars          8
MCP SDK        @modelcontextprotocol/sdk pinned ^0.5.0  (two majors behind)
license        MIT in package.json; no LICENSE file in the repo tree
runtime        Node + native isolated-vm  (our stack is Python end to end)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these is damning on its own. Together they say: adopting means taking on a dormant codebase, in a second language runtime, with a native sandbox dependency (&lt;code&gt;isolated-vm&lt;/code&gt;), pinned to an MCP SDK two majors back — to gain a token efficiency our agent doesn't spend. The maintenance cost is real and the benefit doesn't land. That's the directive &lt;em&gt;not&lt;/em&gt; compelling adoption, on the merits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision framework (reuse this)
&lt;/h2&gt;

&lt;p&gt;Strip out the marine specifics and this is a general adopt-vs-build checklist for an MCP server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Name the target agent first.&lt;/strong&gt; Frontier model or small/local? Chat or voice/TTS? The target decides which currency you're optimizing — tokens or reliability. Most disagreements about MCP design are actually disagreements about the target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identify the binding constraint.&lt;/strong&gt; Is your token budget the wall, or is model reliability the wall? &lt;code&gt;execute_code&lt;/code&gt; trades reliability for tokens. Only adopt it if tokens are the wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check who does the last mile.&lt;/strong&gt; Does the existing tool filter/sort/format/interpret, or hand that to agent-written code? For a small model, every line of last-mile code you push to the agent is a failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the output contract.&lt;/strong&gt; If output is consumed by something with formatting needs (TTS, a strict downstream parser), a raw-data tool externalizes that contract onto the model. Named tools can bake it in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what absence means.&lt;/strong&gt; Make "the thing isn't there" a successful result, not an exception — especially behind a circuit breaker, especially for a model that guesses paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the maturity audit anyway.&lt;/strong&gt; Dormancy, pinned-back SDKs, a second runtime, native deps. Adopting is inheriting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steelman, then harvest.&lt;/strong&gt; If you build your own, still mine the alternative for ideas. A "no" on the architecture isn't a "no" on every idea in it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The honest steelman, and what we harvested
&lt;/h2&gt;

&lt;p&gt;We didn't dismiss VesselSense — we mined it. Three ideas went straight onto our roadmap from reading their server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;get_active_alarms&lt;/code&gt;&lt;/strong&gt; — shipped (v0.5.0). Active notifications, worst-severity-first, normal filtered out, paths prefix-stripped for downstream tooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;list_paths&lt;/code&gt;&lt;/strong&gt; — shipped (v0.3.0). Path discovery so the agent can explore an unfamiliar SignalK tree without guessing — and without tripping the 404 circuit breaker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AIS targets&lt;/strong&gt; — open on the roadmap, with the same speech contract (&lt;code&gt;"cargo vessel, 1.2 nautical miles, bearing North-East"&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And &lt;code&gt;execute_code&lt;/code&gt; stays on the watch-list. The day we add a &lt;strong&gt;cloud-reasoning layer&lt;/strong&gt; — a frontier model doing complex multi-step marine analysis where the token budget genuinely is the wall — code execution is the right tool and we'll reach for it. The evaluation isn't "named tools are better." It's "named tools are better &lt;em&gt;for this agent&lt;/em&gt;, and here's exactly when that flips."&lt;/p&gt;

&lt;p&gt;If you're driving SignalK with a frontier model and want maximum query flexibility, VesselSense is likely the better choice — it's well-built and worth your time. If you want simple, reliable, speakable tools for a local or voice-first agent, that's the niche our server fills.&lt;/p&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, where the agent runs on a small local model behind a Home Assistant voice front end, and "what's our battery?" has to come back as something a human can hear — reliably, every time, on an 8B. The server is open source, and so is the comparison baked into its README: &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-mcp&lt;/a&gt;. Go read VesselSense too — different target, good work.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>signalk</category>
      <category>ai</category>
      <category>voiceassistant</category>
    </item>
    <item>
      <title>Why generic weather MCPs fail for marine navigation (use NDBC buoys)</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 24 Jun 2026 00:13:11 +0000</pubDate>
      <link>https://dev.to/clarkbw--/why-generic-weather-mcps-fail-for-marine-navigation-use-ndbc-buoys-43kk</link>
      <guid>https://dev.to/clarkbw--/why-generic-weather-mcps-fail-for-marine-navigation-use-ndbc-buoys-43kk</guid>
      <description>&lt;p&gt;We run a prime directive on this stack: if a usable tool already exists, improve it; build our own only as a last resort, and when you keep your own, record &lt;em&gt;why&lt;/em&gt; each alternative failed. This post is that audit for &lt;a href="https://github.com/sailingnaturali/weather-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;weather-mcp&lt;/code&gt;&lt;/a&gt; — a marine-weather MCP server — against the weather-MCP ecosystem, and the one capability change that fell out of it.&lt;/p&gt;

&lt;p&gt;The short version: three perfectly good weather MCP servers exist, and none of them does the thing a navigator actually needs. The reasons generalize to &lt;em&gt;any&lt;/em&gt; "adopt an MCP server or keep your own" call, so the audit is the post. Then the fix — parsing a second NDBC file format to split swell from wind waves — is small enough to paste in full, and it surfaced data the standard file had thrown away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, as you'd search it
&lt;/h2&gt;

&lt;p&gt;You want an agent to answer "what are the seas doing where we are?" and you go looking for a marine weather MCP. You find a few. Each one returns a &lt;em&gt;forecast&lt;/em&gt;. None of them returns what a buoy 12 nautical miles away is &lt;em&gt;measuring right now&lt;/em&gt;. That gap — forecast vs. observed — is the entire job, and it's the one thing the ecosystem skips.&lt;/p&gt;

&lt;p&gt;Here's what's on the shelf, and what each one is missing for marine use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The candidates
&lt;/h2&gt;

&lt;p&gt;Three real servers, all worth your time for what they're built for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmer81/open-meteo-mcp           ~13 tools, raw Open-Meteo JSON straight through
weather-mcp/weather-mcp         ~12 tools, own format, global; marine = Open-Meteo
RyanCardin15/NOAA-Tides...      CO-OPS stations: water levels + currents, not buoys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And ours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sailingnaturali/weather-mcp     4 tools, Python, 2 runtime deps (httpx + mcp)
  get_marine_forecast            Open-Meteo wind/swell/wind-wave/seas/pressure
  get_marine_forecast_premium    Stormglass blend — 10 tokens/UTC-day, cache hits free
  get_nearest_buoy_observations  NDBC observed wind + waves by lat/lon, with bearing + age
  get_stormglass_quota_status    token-ledger read, no network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mapped against what a navigator needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;ours&lt;/th&gt;
&lt;th&gt;open-meteo-mcp&lt;/th&gt;
&lt;th&gt;weather-mcp/weather-mcp&lt;/th&gt;
&lt;th&gt;NOAA-Tides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open-Meteo marine (swell / wind-wave split)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes (raw JSON)&lt;/td&gt;
&lt;td&gt;yes (own format)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NDBC nearest-buoy &lt;em&gt;observations&lt;/em&gt; by lat/lon&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no (CO-OPS stations, not buoys)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quota-aware premium tool design&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS-safe &lt;code&gt;{value, display}&lt;/code&gt; contract&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools exposed to the agent&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~13&lt;/td&gt;
&lt;td&gt;~12&lt;/td&gt;
&lt;td&gt;25+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;weather-mcp/weather-mcp&lt;/code&gt; README is admirably honest about the gap — for marine it routes to the Open-Meteo Marine API, which is a forecast model, not an observation. None of these is a bad server. They're built for a chat-window agent asking general weather questions. We're building for a voice-first agent on a small local model doing navigation. Different target, different binding constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failure axes that generalize
&lt;/h2&gt;

&lt;p&gt;Strip out the marine specifics and the audit comes down to three things a generic server can't give an agent, plus one that hurts small models specifically.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Buoy ground-truthing — observations, not just forecasts
&lt;/h3&gt;

&lt;p&gt;The core doctrine is "forecast vs. observed, lead with the observation." A forecast says the seas &lt;em&gt;should&lt;/em&gt; be 1.5 m; a buoy 12 nm upwind says they &lt;em&gt;are&lt;/em&gt; 2.4 m and building. The agent should say the second thing. No generic weather MCP does observations at all — they're all forecast APIs. The NDBC realtime network is the data source, and wrapping it is the whole reason our server exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Quota-aware tool design
&lt;/h3&gt;

&lt;p&gt;The premium provider (Stormglass) has a hard free-tier wall: 10 requests per UTC day. An agent that can't &lt;em&gt;see&lt;/em&gt; that budget will burn it in one chatty session. So the budget has to be expressible in the tool surface itself — a paid tool and a free "how many tokens are left?" tool, paired:&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="c1"&gt;# get_stormglass_quota_status — no network, just reads the ledger
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_stormglass_quota_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quota&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;StormglassQuota&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;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;...&lt;/span&gt;   &lt;span class="c1"&gt;# used / remaining for the current UTC day
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"This call costs 1 of 10 daily tokens; cache hits are free" is a &lt;em&gt;design property of the tool surface&lt;/em&gt;, not a footnote in a README. No generic server models cost, because for a free forecast API there's no cost to model.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The display contract
&lt;/h3&gt;

&lt;p&gt;Every value our tools return carries a pre-formatted, TTS-safe &lt;code&gt;display&lt;/code&gt; string the agent speaks verbatim — because small local models mangle re-formatting, and a text-to-speech engine mispronounces raw units and codes. (We've written before about why &lt;a href="https://engineering.sailingnaturali.com/fix-llm-formatting-in-the-tool-layer-not-the-prompt/" rel="noopener noreferrer"&gt;formatting belongs in the tool layer, not the prompt&lt;/a&gt;.) A server that returns raw API JSON pushes that formatting onto the model — the exact layer that fails. Generic servers return raw or near-raw payloads. That's correct for a frontier model in a chat window and disqualifying for ours.&lt;/p&gt;

&lt;h3&gt;
  
  
  And: tool-count bloat
&lt;/h3&gt;

&lt;p&gt;4 tools vs. ~13, ~12, 25+. The MCP field has converged on a real number here: tool-selection accuracy on smaller models degrades as the surface grows, and the common advice is to keep a server in the 5–8 range and split domains past ~15. A curated 4-tool surface isn't minimalism for its own sake — it's the thing that keeps an 8B picking the right tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: keep, with receipts
&lt;/h2&gt;

&lt;p&gt;Per the directive, every alternative gets a recorded reason:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;open-meteo-mcp&lt;/strong&gt; — covers only the Open-Meteo forecast leg, returns raw JSON (breaks the display contract), ~13 tools. No buoys, no premium, no observations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;weather-mcp/weather-mcp&lt;/strong&gt; — same gaps, more tools, marine is forecast-only by its own README.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NOAA-Tides&lt;/strong&gt; — CO-OPS water levels and currents; overlaps a tide server's domain, not a wave-observation server's. No buoy waves, no forecasts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splitting&lt;/strong&gt; (someone's forecast + our buoys) — doubles config surface and the most-called tool loses the display contract.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep ours. Now close its biggest documented gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest twist: a library-level adopt we &lt;em&gt;also&lt;/em&gt; rejected
&lt;/h2&gt;

&lt;p&gt;"Prefer adopting" doesn't stop at servers — there's a library that does exactly the NDBC parsing we hand-roll: &lt;a href="https://github.com/CDJellen/ndbc-api" rel="noopener noreferrer"&gt;&lt;code&gt;CDJellen/ndbc-api&lt;/code&gt;&lt;/a&gt;, a well-maintained Python package for NDBC data. The directive says use it. We didn't, and the reason is a clause worth naming out loud: &lt;strong&gt;install weight is an adoption barrier for a &lt;code&gt;uvx&lt;/code&gt;-installable server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ndbc-api&lt;/code&gt; pulls a scientific-computing stack — pandas, numpy, scipy, xarray, h5netcdf, beautifulsoup4, html5lib. Adopting it to replace ~220 lines of whitespace-table parsing would take the server from &lt;strong&gt;2 runtime dependencies to ~9&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;weather-mcp today:   httpx, mcp
with ndbc-api:       httpx, mcp, ndbc-api
                       └─ pandas, numpy, scipy, xarray, h5netcdf, beautifulsoup4, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a server people install with &lt;code&gt;uvx weather-mcp&lt;/code&gt;, every transitive dependency is cold-start latency and a bigger surface to break. We're optimizing the public artifact, not just our own runtime. Adopt-first has a dependency-weight escape clause, and this is what it looks like in practice. So: keep the hand parser, and extend it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: parsing NDBC &lt;code&gt;.spec&lt;/code&gt; to split swell from wind waves
&lt;/h2&gt;

&lt;p&gt;Our biggest documented limitation was sitting in the buoy tool itself. The standard NDBC realtime file (&lt;code&gt;realtime2/{station}.txt&lt;/code&gt;) reports &lt;strong&gt;combined&lt;/strong&gt; significant wave height only — one number that smears swell and local wind chop together. For navigation that distinction matters: 0.4 m of short-period wind chop is a different sea state than 0.4 m of long-period swell from a distant storm.&lt;/p&gt;

&lt;p&gt;NDBC publishes the separation in a &lt;em&gt;second&lt;/em&gt; file alongside the &lt;code&gt;.txt&lt;/code&gt; — &lt;code&gt;realtime2/{station}.spec&lt;/code&gt; — same whitespace-table format, with swell and wind waves broken out. The header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#YY  MM DD hh mm WVHT  SwH  SwP  WWH  WWP SwD WWD  STEEPNESS  APD MWD
2026 06 06 04 10  0.4  0.1  6.2  0.4  2.9 WSW   W        N/A  2.9 273
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SwH&lt;/code&gt; / &lt;code&gt;SwP&lt;/code&gt; / &lt;code&gt;SwD&lt;/code&gt; — swell height (m), period (s), direction (compass string)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WWH&lt;/code&gt; / &lt;code&gt;WWP&lt;/code&gt; / &lt;code&gt;WWD&lt;/code&gt; — wind-wave height, period, direction&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;STEEPNESS&lt;/code&gt; — qualitative, may be &lt;code&gt;N/A&lt;/code&gt;; &lt;code&gt;MM&lt;/code&gt; marks missing values, same as &lt;code&gt;.txt&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser mirrors the existing &lt;code&gt;.txt&lt;/code&gt; parser — first data row, &lt;code&gt;MM&lt;/code&gt;-as-&lt;code&gt;None&lt;/code&gt;, directions kept as compass strings (no degree round-trip, because the display layer wants "WSW" not 247°):&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="n"&gt;SpecWaves&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Parse the first data row of a realtime2 .spec response.

    Directions arrive as compass strings (e.g. &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;WSW&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) and are kept as-is.
    Returns None if there are no data rows or every wave field is missing.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&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;ln&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;cols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rows&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;yyyy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cols&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yyyy&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dd&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hh&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SpecWaves&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;observed_utc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;swell_height_m&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;swell_period_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;swell_dir_compass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;wind_wave_height_m&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;wind_wave_period_s&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;wind_wave_dir_compass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;steepness&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_maybe_str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]),&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;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;swell_height_m&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wind_wave_height_m&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two files, two failure modes, so the merge degrades gracefully. The &lt;code&gt;.spec&lt;/code&gt; is fetched &lt;strong&gt;concurrently&lt;/strong&gt; with the &lt;code&gt;.txt&lt;/code&gt;, and a missing or &lt;em&gt;stale&lt;/em&gt; &lt;code&gt;.spec&lt;/code&gt; (404, or an observation time skewed more than an hour from the &lt;code&gt;.txt&lt;/code&gt; row) just drops the swell fields — the tool output is byte-identical to the old combined-only behavior:&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="n"&gt;MAX_SPEC_SKEW_S&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;  &lt;span class="c1"&gt;# .spec off the .txt row by &amp;gt;1h → treat as stale
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;merge_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BuoyObservation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;SpecWaves&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;BuoyObservation&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;spec&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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;obs&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observed_utc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;observed_utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MAX_SPEC_SKEW_S&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;obs&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;swell_height_m&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;swell_height_m&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# both files, one round trip; .txt is the source of truth, .spec only enriches
&lt;/span&gt;&lt;span class="n"&gt;txt_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;_get_text_or_none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;txt_url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;_get_text_or_none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec_url&lt;/span&gt;&lt;span class="p"&gt;),&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;txt_text&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="n"&gt;obs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_realtime2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txt_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;station&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref_lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref_lon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;spec_text&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;spec_text&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;merge_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merged observation rides the existing cache key; the &lt;code&gt;.spec&lt;/code&gt; is never fetched on its own, so it needs no cache key of its own. And the absence case stays honest in the output — the note only appears when separation is actually missing:&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_spec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;wave_block&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;combined waves only — this station&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s .spec swell separation is unavailable or stale&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The receipt
&lt;/h2&gt;

&lt;p&gt;This isn't theoretical. Pulled live from station &lt;strong&gt;46088 (New Dungeness)&lt;/strong&gt;, the standard &lt;code&gt;.txt&lt;/code&gt; file reported &lt;strong&gt;no combined wave height at all&lt;/strong&gt; — &lt;code&gt;WVHT&lt;/code&gt; came back &lt;code&gt;MM&lt;/code&gt;, missing. The &lt;code&gt;.spec&lt;/code&gt; file, same station, same minute, had the separation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.txt   →  WVHT MM            (no combined wave height — missing)
.spec  →  swell 0.1 m WSW at 5.6 s, wind wave 0.4 m W
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The separation didn't just refine an existing number — it &lt;strong&gt;surfaced wave data that was invisible&lt;/strong&gt; in the file everyone parses. The combined field was missing; the component fields were there the whole time, in a file most NDBC wrappers ignore. ~60 lines plus tests, two new runtime dependencies (zero), one closed gap.&lt;/p&gt;

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

&lt;p&gt;The reusable lesson isn't about waves. It's that &lt;strong&gt;an MCP server's value to an agent is the tool &lt;em&gt;design&lt;/em&gt; — how it handles absence, whether it models cost, what contract its output guarantees — not API-coverage breadth.&lt;/strong&gt; Three servers with more tools and broader coverage each failed the same navigator on the same three axes: no observations, no quota awareness, no speakable output. Coverage is easy to add and easy to find; design for a specific agent is the part you can't adopt off the shelf.&lt;/p&gt;

&lt;p&gt;And the dependency-weight clause cuts the other way against "always adopt the library": for a tool people install with one command, ~220 lines of stdlib parsing can be the &lt;em&gt;right&lt;/em&gt; call over a maintained package, when the package drags a scientific-computing stack behind it. Adopt-first, with an escape clause you can defend.&lt;/p&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, where the weather agent has to ground its forecasts against real buoys and say the answer out loud, on a small local model, reliably. The server and the audit baked into its README are open: &lt;a href="https://github.com/sailingnaturali/weather-mcp" rel="noopener noreferrer"&gt;github.com/sailingnaturali/weather-mcp&lt;/a&gt;. Go read the three servers above too — all good work, just built for a different boat.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>marine</category>
      <category>weather</category>
      <category>ndbc</category>
    </item>
  </channel>
</rss>
